From 6f08cb1363368068828a822b9d7a899d084cbfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=87=E5=AE=99=E7=9A=84=E5=B1=B1=E5=B3=B0?= <113168673+UniversePeak@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:17:41 +0800 Subject: [PATCH 1/3] fix(cli): resolve archive worktrees from machine Flock Model: gpt-5.6-luna --- apps/cli/src/lib/message-handler.ts | 11 +++- .../message-handler-terminal-cleanup.test.ts | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index ed64fce45..71d491871 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -3810,7 +3810,12 @@ export class MessageHandler { this.getMachineFlockDocIdForMachine() ); return readMachineFlockRowsFromFlock(handle.flock, { - families: ['archiveSessionCommand', 'deleteSessionCommand', 'deleteLocalProjectCommand'], + families: [ + 'archiveSessionCommand', + 'deleteSessionCommand', + 'deleteLocalProjectCommand', + 'localProject', + ], }); } @@ -3968,9 +3973,10 @@ export class MessageHandler { this.logger.debug(`[${sessionId}] Preview tunnel closed for archive`); const sessionRoomId = getSessionRoomId(sessionId); - const [sessionMetaDoc, archiveMachineMetaDoc] = await Promise.all([ + const [sessionMetaDoc, archiveMachineMetaDoc, archiveMachineFlockRows] = await Promise.all([ this.workspaceDocument.repo.getDocMeta(sessionRoomId), this.workspaceDocument.repo.getDocMeta(getMachineRoomId(this.machineId)), + this.tryReadMachineFlockCommandRows(), ]); const sessionMeta = sessionMetaDoc?.meta as SessionMeta | undefined; const archiveMachineMeta = archiveMachineMetaDoc?.meta as MachineLegacyMetaFields | undefined; @@ -3986,6 +3992,7 @@ export class MessageHandler { const cleanupTarget = this.resolveWorktreeCleanupTarget({ sessionMeta, machineMeta: archiveMachineMeta, + machineFlockRows: archiveMachineFlockRows, }); if (cleanupTarget) { const worktreeManager = getWorktreeManager(this.buildWorktreeManagerConfig(cleanupTarget)); diff --git a/apps/cli/tests/message-handler-terminal-cleanup.test.ts b/apps/cli/tests/message-handler-terminal-cleanup.test.ts index 30963d92c..11f520f60 100644 --- a/apps/cli/tests/message-handler-terminal-cleanup.test.ts +++ b/apps/cli/tests/message-handler-terminal-cleanup.test.ts @@ -224,6 +224,63 @@ function createHarness(options?: { } describe('MessageHandler terminal cleanup', () => { + it('cleans a local worktree when its project metadata exists only in the machine Flock', async () => { + const localProjectId = 'local-project-archive' as LocalProjectId; + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-archive-project-')); + const originalDataDir = process.env.LODY_DATA_DIR; + const originalLocksDir = process.env.LODY_LOCKS_DIR; + process.env.LODY_DATA_DIR = path.join(testDir, 'data'); + process.env.LODY_LOCKS_DIR = path.join(testDir, 'locks'); + const rootPath = createLocalRepo(testDir); + const sessionId = 'session-archive-worktree' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'machine-1', + createdAt: '2026-01-01T00:00:00.000Z', + userId: 'user-1', + cliType: 'codex', + agentType: 'codex', + status: SessionStatusFactory.idle(), + project: { kind: 'local', localProjectId }, + isWorktree: true, + } as SessionMeta; + const machineFlockRows = [ + { + key: machineFlockKeys.localProject(localProjectId), + value: { + id: localProjectId, + name: 'Project', + rootPath, + createdAtMs: 1, + }, + }, + ]; + try { + const manager = getWorktreeManager({ + repoId: deriveRepoIdFromLocalProjectPath(rootPath), + source: { kind: 'local-shared', originalRootPath: rootPath }, + logger: createSilentLogger(), + }); + const worktree = await manager.createWorktree(sessionId); + const { handler, sessionManager } = createHarness({ + sessionId, + sessionMetas: [sessionMeta], + machineFlockRows, + }); + + await handler.archiveSessionResources(sessionId); + + expect(fs.existsSync(worktree.hostPath)).toBe(false); + expect(sessionManager.archiveSession).toHaveBeenCalledWith(sessionId); + } finally { + if (originalDataDir === undefined) delete process.env.LODY_DATA_DIR; + else process.env.LODY_DATA_DIR = originalDataDir; + if (originalLocksDir === undefined) delete process.env.LODY_LOCKS_DIR; + else process.env.LODY_LOCKS_DIR = originalLocksDir; + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + it('closes session terminals when archiving resources even without an active session', async () => { const { handler, sessionId, closeSessionTerminals, sessionManager } = createHarness(); From c731b12cfd263412eb8aab32ac7a615df71163e7 Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:16:42 +0800 Subject: [PATCH 2/3] fix(cli): retry archive after Machine Flock read failures Reuse the archive command snapshot and keep the request queued when the authoritative Machine Flock cannot be read. Add a regression for the transient read failure.\n\nModel: gpt-5.6-luna --- apps/cli/src/lib/message-handler.ts | 34 +++++++++---- .../message-handler-terminal-cleanup.test.ts | 48 ++++++++++++++----- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 71d491871..4ccec0ec0 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -3833,11 +3833,16 @@ export class MessageHandler { } } - private async readMachineCommandSnapshot(): Promise { + private async readMachineCommandSnapshot(options?: { + strictFlock?: boolean; + }): Promise { const machineRoomId = getMachineRoomId(this.machineId); + const flockRows = options?.strictFlock + ? this.readMachineFlockCommandRows() + : this.tryReadMachineFlockCommandRows(); const [machineMetaDoc, machineFlockRows] = await Promise.all([ this.workspaceDocument.repo.getDocMeta(machineRoomId), - this.tryReadMachineFlockCommandRows(), + flockRows, ]); return buildMachineCommandSnapshot( machineMetaDoc?.meta as MachineLegacyMetaFields | undefined, @@ -3920,7 +3925,15 @@ export class MessageHandler { } private async processArchiveRequests(): Promise { - const snapshot = await this.readMachineCommandSnapshot(); + let snapshot: MachineCommandSnapshot; + try { + snapshot = await this.readMachineCommandSnapshot({ strictFlock: true }); + } catch (error) { + this.logger.error( + `[archive] Failed to read Machine Flock; keeping archive request queued: ${formatErrorMessage(error)}` + ); + return; + } const sessionIds = snapshot.archiveSessionIds; if (sessionIds.length === 0) { this.logger.debug('[archive] No pending archive requests'); @@ -3943,7 +3956,9 @@ export class MessageHandler { this.archiveInFlight.add(sessionId); try { this.logger.debug(`[archive] Start archiving session ${sessionId}`); - await this.archiveSessionResources(sessionId); + await this.archiveSessionResources(sessionId, { + machineFlockRows: snapshot.machineFlockRows, + }); await this.removeArchiveRequest(sessionId); this.logger.debug(`[archive] Finished archiving session ${sessionId}`); } catch (error) { @@ -3958,7 +3973,10 @@ export class MessageHandler { private async archiveSessionResources( sessionId: SessionId, - options?: { preserveWorktree?: boolean } + options?: { + preserveWorktree?: boolean; + machineFlockRows?: MachineFlockRowMap; + } ): Promise { this.logger.debug(`[${sessionId}] Archiving session resources`); @@ -3973,10 +3991,9 @@ export class MessageHandler { this.logger.debug(`[${sessionId}] Preview tunnel closed for archive`); const sessionRoomId = getSessionRoomId(sessionId); - const [sessionMetaDoc, archiveMachineMetaDoc, archiveMachineFlockRows] = await Promise.all([ + const [sessionMetaDoc, archiveMachineMetaDoc] = await Promise.all([ this.workspaceDocument.repo.getDocMeta(sessionRoomId), this.workspaceDocument.repo.getDocMeta(getMachineRoomId(this.machineId)), - this.tryReadMachineFlockCommandRows(), ]); const sessionMeta = sessionMetaDoc?.meta as SessionMeta | undefined; const archiveMachineMeta = archiveMachineMetaDoc?.meta as MachineLegacyMetaFields | undefined; @@ -3992,7 +4009,8 @@ export class MessageHandler { const cleanupTarget = this.resolveWorktreeCleanupTarget({ sessionMeta, machineMeta: archiveMachineMeta, - machineFlockRows: archiveMachineFlockRows, + machineFlockRows: + options?.machineFlockRows ?? (await this.tryReadMachineFlockCommandRows()), }); if (cleanupTarget) { const worktreeManager = getWorktreeManager(this.buildWorktreeManagerConfig(cleanupTarget)); diff --git a/apps/cli/tests/message-handler-terminal-cleanup.test.ts b/apps/cli/tests/message-handler-terminal-cleanup.test.ts index 11f520f60..4bee15ddc 100644 --- a/apps/cli/tests/message-handler-terminal-cleanup.test.ts +++ b/apps/cli/tests/message-handler-terminal-cleanup.test.ts @@ -39,9 +39,10 @@ const createSilentLogger = (): Logger => ({ }); type MessageHandlerInternals = { + processArchiveRequests: () => Promise; archiveSessionResources: ( sessionId: SessionId, - options?: { preserveWorktree?: boolean } + options?: { preserveWorktree?: boolean; machineFlockRows?: Record } ) => Promise; deleteLocalProjectResources: ( localProjectId: LocalProjectId, @@ -75,8 +76,10 @@ function createHarness(options?: { machineFlockRows?: MachineFlockScanRow[]; sessionMetas?: SessionMeta[]; activeSessionIds?: SessionId[]; + archiveSessionIds?: SessionId[]; includeLegacySessionDeleteRequest?: boolean; localProjectRootPaths?: Record; + machineFlockOpenError?: Error; }) { const sessionId = options?.sessionId ?? ('session-1' as SessionId); const childSessionIds = options?.childSessionIds ?? []; @@ -128,7 +131,9 @@ function createHarness(options?: { if (roomId === machineRoomId) { return { meta: { - needToArchiveSessions: {}, + needToArchiveSessions: Object.fromEntries( + (options?.archiveSessionIds ?? []).map((id) => [id, true]) + ), needToDeleteSessions: options?.includeLegacySessionDeleteRequest === false ? {} : { [sessionId]: true }, localProjects: Object.fromEntries( @@ -149,15 +154,20 @@ function createHarness(options?: { : [] ), })), - openFlockDoc: vi.fn(async () => ({ - flock: { - scan: () => machineFlockRows, - set: flockSet, - delete: flockDelete, - commit: flockCommit, - }, - syncOnce: vi.fn(async () => {}), - })), + openFlockDoc: vi.fn(async () => { + if (options?.machineFlockOpenError) { + throw options.machineFlockOpenError; + } + return { + flock: { + scan: () => machineFlockRows, + set: flockSet, + delete: flockDelete, + commit: flockCommit, + }, + syncOnce: vi.fn(async () => {}), + }; + }), upsertDocMeta: vi.fn(async (roomId: string, patch: Partial) => { events.push(`meta:${roomId}:${patch.isArchived === true ? 'archived' : 'other'}`); const current = sessionMetas.get(roomId); @@ -290,6 +300,22 @@ describe('MessageHandler terminal cleanup', () => { expect(sessionManager.terminateSession).not.toHaveBeenCalled(); }); + it('keeps archive requests queued when the Machine Flock read fails', async () => { + const sessionId = 'session-archive-flock-read-failure' as SessionId; + const { handler, sessionManager, repo } = createHarness({ + sessionId, + archiveSessionIds: [sessionId], + machineFlockOpenError: new Error('temporary Machine Flock failure'), + }); + + await expect(handler.processArchiveRequests()).resolves.toBeUndefined(); + expect(sessionManager.archiveSession).not.toHaveBeenCalled(); + expect(repo.upsertDocMeta).not.toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ isArchived: true }) + ); + }); + it('closes parent and active child terminals before permanent deletion cleanup', async () => { const childSessionId = 'child-1' as SessionId; const { handler, sessionId, closeSessionTerminals } = createHarness({ From 03e2a9f022e975c95a91ceffa74d7edbb341cde8 Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:32:18 +0800 Subject: [PATCH 3/3] fix(cli): wait for authoritative archive metadata Model: gpt-5.6-luna --- apps/cli/src/lib/message-handler.ts | 8 ++ .../message-handler-terminal-cleanup.test.ts | 84 ++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 4ccec0ec0..843b0a8ed 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -3925,6 +3925,14 @@ export class MessageHandler { } private async processArchiveRequests(): Promise { + // Archive cleanup may need a local-project root that exists only in Machine + // Flock. In cloud mode, wait until its command watcher has completed the + // initial remote sync; a successful read before then can still be stale. + if (this.cloudPort.kind !== 'local' && !this.machineFlockCommandWatcher.isReady) { + this.logger.debug('[archive] Waiting for authoritative Machine Flock before processing'); + return; + } + let snapshot: MachineCommandSnapshot; try { snapshot = await this.readMachineCommandSnapshot({ strictFlock: true }); diff --git a/apps/cli/tests/message-handler-terminal-cleanup.test.ts b/apps/cli/tests/message-handler-terminal-cleanup.test.ts index 4bee15ddc..0fcacd476 100644 --- a/apps/cli/tests/message-handler-terminal-cleanup.test.ts +++ b/apps/cli/tests/message-handler-terminal-cleanup.test.ts @@ -18,6 +18,7 @@ import { type SessionMeta, type WorkspaceId, } from '@lody/shared'; +import type { CloudPort } from '@lody/platform'; import { deriveRepoIdFromLocalProjectPath } from '@lody/shared/node/worktree-paths'; import { MessageHandler } from '../src/lib/message-handler'; import type { LoroDocumentManager } from '../src/lib/loro/doc'; @@ -67,6 +68,7 @@ type MessageHandlerInternals = { previewService: { closeSessionPreviewForCleanup: (sessionId: SessionId, reason: string) => Promise; }; + machineFlockCommandWatcher: { isReady: boolean }; }; function createHarness(options?: { @@ -80,6 +82,7 @@ function createHarness(options?: { includeLegacySessionDeleteRequest?: boolean; localProjectRootPaths?: Record; machineFlockOpenError?: Error; + cloudPort?: CloudPort; }) { const sessionId = options?.sessionId ?? ('session-1' as SessionId); const childSessionIds = options?.childSessionIds ?? []; @@ -209,7 +212,7 @@ function createHarness(options?: { machineName: 'machine', cliVersion: '0.0.0', closeSessionTerminals, - cloudPort: createTestCloudPort(), + cloudPort: options?.cloudPort ?? createTestCloudPort(), } ); const internal = handler as unknown as MessageHandlerInternals; @@ -300,6 +303,85 @@ describe('MessageHandler terminal cleanup', () => { expect(sessionManager.terminateSession).not.toHaveBeenCalled(); }); + it('keeps a cloud archive request queued until the Machine Flock watcher is authoritative', async () => { + const localProjectId = 'local-project-archive-authority' as LocalProjectId; + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-archive-authority-')); + const originalDataDir = process.env.LODY_DATA_DIR; + const originalLocksDir = process.env.LODY_LOCKS_DIR; + process.env.LODY_DATA_DIR = path.join(testDir, 'data'); + process.env.LODY_LOCKS_DIR = path.join(testDir, 'locks'); + const rootPath = createLocalRepo(testDir); + const sessionId = 'session-archive-awaiting-authority' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'machine-1', + createdAt: '2026-01-01T00:00:00.000Z', + userId: 'user-1', + cliType: 'codex', + agentType: 'codex', + status: SessionStatusFactory.idle(), + project: { kind: 'local', localProjectId }, + isWorktree: true, + } as SessionMeta; + const machineFlockRows = [ + { + key: machineFlockKeys.localProject(localProjectId), + value: { id: localProjectId, name: 'Project', rootPath, createdAtMs: 1 }, + }, + ]; + const cloudPort = { ...createTestCloudPort(), kind: 'cloud' } as CloudPort; + try { + const manager = getWorktreeManager({ + repoId: deriveRepoIdFromLocalProjectPath(rootPath), + source: { kind: 'local-shared', originalRootPath: rootPath }, + logger: createSilentLogger(), + }); + const worktree = await manager.createWorktree(sessionId); + const { handler, repo, sessionManager } = createHarness({ + sessionId, + sessionMetas: [sessionMeta], + archiveSessionIds: [sessionId], + includeLegacySessionDeleteRequest: false, + machineFlockRows, + cloudPort, + }); + handler.machineFlockCommandWatcher = { isReady: false }; + + await handler.processArchiveRequests(); + + expect(fs.existsSync(worktree.hostPath)).toBe(true); + expect(sessionManager.archiveSession).not.toHaveBeenCalled(); + expect(repo.upsertDocMeta).not.toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ isArchived: true }) + ); + expect(repo.upsertDocMeta).not.toHaveBeenCalledWith( + getMachineRoomId('machine-1'), + expect.objectContaining({ needToArchiveSessions: {} }) + ); + + handler.machineFlockCommandWatcher.isReady = true; + await handler.processArchiveRequests(); + + expect(fs.existsSync(worktree.hostPath)).toBe(false); + expect(sessionManager.archiveSession).toHaveBeenCalledWith(sessionId); + expect(repo.upsertDocMeta).toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ isArchived: true }) + ); + expect(repo.upsertDocMeta).toHaveBeenCalledWith( + getMachineRoomId('machine-1'), + expect.objectContaining({ needToArchiveSessions: {} }) + ); + } finally { + if (originalDataDir === undefined) delete process.env.LODY_DATA_DIR; + else process.env.LODY_DATA_DIR = originalDataDir; + if (originalLocksDir === undefined) delete process.env.LODY_LOCKS_DIR; + else process.env.LODY_LOCKS_DIR = originalLocksDir; + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + it('keeps archive requests queued when the Machine Flock read fails', async () => { const sessionId = 'session-archive-flock-read-failure' as SessionId; const { handler, sessionManager, repo } = createHarness({