diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index a9cd0613d..6aa3e28c1 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -1542,6 +1542,69 @@ describe('session command helpers', () => { } }); + it('continues local project resolution when machine Flock freshness sync fails', async () => { + const rootPath = mkdtempSync(path.join(os.tmpdir(), 'lody-session-git-project-')); + try { + execFileSync('git', ['init'], { cwd: rootPath, stdio: 'ignore' }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--allow-empty', + '-m', + 'init', + ], + { cwd: rootPath, stdio: 'ignore' } + ); + const syncFlockDocOrThrow = vi + .fn() + .mockRejectedValue(new Error('Streams sync failed: network_error')); + const manager = { + syncFlockDocOrThrow, + repo: { + getDocMeta: vi.fn(async () => ({ + meta: { + localProjects: { + 'local-project-1': { + id: 'local-project-1', + name: 'lody', + rootPath, + createdAtMs: 1, + }, + }, + }, + })), + openFlockDoc: vi.fn(async () => ({ + flock: { + scan: () => [], + }, + })), + }, + } as unknown as Parameters[0]; + + await expect( + resolveLocalProjectRefOrThrow( + manager, + 'workspace-1' as WorkspaceId, + 'machine-id' as MachineId, + 'lody' + ) + ).resolves.toEqual({ + kind: 'local', + localProjectId: 'local-project-1', + }); + expect(syncFlockDocOrThrow).toHaveBeenCalledWith( + getMachineFlockDocId('workspace-1' as WorkspaceId, 'machine-id' as MachineId), + expect.objectContaining({ reason: 'session.local-projects:machine-id' }) + ); + } finally { + rmSync(rootPath, { recursive: true, force: true }); + } + }); it('does not synthesize a branch for non-git local projects', async () => { const rootPath = mkdtempSync(path.join(os.tmpdir(), 'lody-session-non-git-')); try { diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 3f9f21164..59fc2e896 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -1005,6 +1005,31 @@ async function syncMachineFlockDocsForRead( ); } +async function syncMachineFlockDocsForReadBestEffort( + manager: LoroDocumentManager, + workspaceId: WorkspaceId, + machineIds: readonly MachineId[], + reason: string +): Promise> { + const logger = getLogger('session'); + const syncErrors = new Map(); + await Promise.all( + Array.from(new Set(machineIds)).map(async (machineId) => { + try { + await syncMachineFlockDocsForRead(manager, workspaceId, [machineId], reason); + } catch (error) { + const message = formatErrorMessage(error); + syncErrors.set(machineId, message); + logger.warn( + `Machine Flock freshness sync did not complete (${reason}:${machineId}); ` + + `continuing with the local replica: ${message}` + ); + } + }) + ); + return syncErrors; +} + export async function resolveLocalProjectRefOrThrow( manager: LoroDocumentManager, workspaceId: WorkspaceId, @@ -1013,12 +1038,22 @@ export async function resolveLocalProjectRefOrThrow( requestedBranch?: string, useWorktree?: boolean ): Promise { - await syncMachineFlockDocsForRead(manager, workspaceId, [machineId], 'session.local-projects'); + const syncErrors = await syncMachineFlockDocsForReadBestEffort( + manager, + workspaceId, + [machineId], + 'session.local-projects' + ); + const syncError = syncErrors.get(machineId); + const syncErrorSuffix = syncError ? ` Flock freshness sync failed: ${syncError}` : ''; + const withSyncError = (message: string): string => `${message}${syncErrorSuffix}`; const localProjects = Object.values( await readMachineLocalProjects(manager.repo, workspaceId, machineId) ); if (localProjects.length === 0) { - throw new Error('No local project is registered on this machine for the target workspace.'); + throw new Error( + withSyncError('No local project is registered on this machine for the target workspace.') + ); } const normalizedSelector = normalizeCliValue(selector); @@ -1030,9 +1065,11 @@ export async function resolveLocalProjectRefOrThrow( if (matches.length === 0) { throw new Error( - `Local project not found: ${normalizedSelector}. Candidates: ${localProjects - .map((project) => `${project.name} (${project.id})`) - .join(', ')}` + withSyncError( + `Local project not found: ${normalizedSelector}. Candidates: ${localProjects + .map((project) => `${project.name} (${project.id})`) + .join(', ')}` + ) ); } if (matches.length > 1) { @@ -2406,8 +2443,13 @@ async function listAgentConfigsForMachine( manager: LoroDocumentManager, workspaceId: WorkspaceId, machineId: MachineId -): Promise { - await syncMachineFlockDocsForRead(manager, workspaceId, [machineId], 'session.agent-configs'); +): Promise<{ configs: AgentConfigMeta[]; syncError?: string }> { + const syncErrors = await syncMachineFlockDocsForReadBestEffort( + manager, + workspaceId, + [machineId], + 'session.agent-configs' + ); const configs = await listMergedAgentConfigs(manager.repo, workspaceId, [machineId]); configs.sort((left, right) => { const nameCompare = left.name.localeCompare(right.name); @@ -2416,7 +2458,8 @@ async function listAgentConfigsForMachine( } return left.id.localeCompare(right.id); }); - return configs; + const syncError = syncErrors.get(machineId); + return { configs, ...(syncError ? { syncError } : {}) }; } export function selectDefaultAgentConfigForCreate( @@ -2449,9 +2492,17 @@ async function resolveAgentConfigForCreate(args: { selector?: string; currentSession?: SessionMeta; }): Promise { - const configs = await listAgentConfigsForMachine(args.manager, args.workspaceId, args.machineId); + const { configs, syncError } = await listAgentConfigsForMachine( + args.manager, + args.workspaceId, + args.machineId + ); if (configs.length === 0) { - throw new Error(`No agent config exists on machine ${args.machineId}.`); + throw new Error( + `No agent config exists on machine ${args.machineId}.${ + syncError ? ` Flock freshness sync failed: ${syncError}` : '' + }` + ); } const selector = normalizeCliValue(args.selector) ?? normalizeCliValue(process.env.LODY_AGENT_CONFIG_ID); @@ -2683,12 +2734,22 @@ async function resolveLocalProjectRefOnMachineOrThrow( requestedBranch?: string, useWorktree?: boolean ): Promise { - await syncMachineFlockDocsForRead(manager, workspaceId, [machineId], 'session.local-projects'); + const syncErrors = await syncMachineFlockDocsForReadBestEffort( + manager, + workspaceId, + [machineId], + 'session.local-projects' + ); + const syncError = syncErrors.get(machineId); + const syncErrorSuffix = syncError ? ` Flock freshness sync failed: ${syncError}` : ''; + const withSyncError = (message: string): string => `${message}${syncErrorSuffix}`; const localProjects = Object.values( await readMachineLocalProjects(manager.repo, workspaceId, machineId) ); if (localProjects.length === 0) { - throw new Error('No local project is registered on the target machine for this workspace.'); + throw new Error( + withSyncError('No local project is registered on the target machine for this workspace.') + ); } const authorizedLocalProjects = await filterAuthorizedLocalProjectsForCreate({ auth, @@ -2707,9 +2768,11 @@ async function resolveLocalProjectRefOnMachineOrThrow( const matches = selectLocalProjectsBySelector(authorizedLocalProjects, normalizedSelector); if (matches.length === 0) { throw new Error( - `Local project not found on ${machineId}: ${normalizedSelector}. Candidates: ${authorizedLocalProjects - .map((project) => `${project.name} (${project.id})`) - .join(', ')}` + withSyncError( + `Local project not found on ${machineId}: ${normalizedSelector}. Candidates: ${authorizedLocalProjects + .map((project) => `${project.name} (${project.id})`) + .join(', ')}` + ) ); } if (matches.length > 1) {