diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e0392a124..0abe620e9 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -4356,6 +4356,62 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi } }); +test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents are archived", async () => { + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-archive-status-entered", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archive-status-entered", + projectId: project.projectId, + cwd: "/tmp/repo/archive-status-entered", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const doneEnteredAt = "2026-05-12T09:30:00.000Z"; + const archivedAt = "2026-05-12T09:45:00.000Z"; + + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt: doneEnteredAt, + }), + ]; + + const first = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); + expect(first.get(workspace.workspaceId)?.status).toBe("done"); + expect(first.get(workspace.workspaceId)?.statusEnteredAt).toBe(doneEnteredAt); + + session.listAgentPayloads = async () => [ + { + ...makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt: doneEnteredAt, + }), + archivedAt, + }, + ]; + + const second = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); + expect(second.get(workspace.workspaceId)).toMatchObject({ + status: "done", + statusEnteredAt: doneEnteredAt, + }); +}); + test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => { const session = createSessionForWorkspaceTests(); const archivingAt = "2026-04-30T20:45:00.000Z"; diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index b969c88e7..2bebb41bf 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -30,7 +30,7 @@ const FETCH_WORKSPACES_SORT_KEYS = [ * mask to a lower-priority bucket, the new entry time is the unmask time * (i.e., the moment the higher-priority bucket cleared), not when the * underlying agent originally entered the lower-priority bucket. Cleared when - * the workspace becomes `done` with no contributing agents. + * the workspace has never had contributing agents. */ interface WorkspaceBucketHistoryEntry { bucket: WorkspaceStateBucket; @@ -279,7 +279,10 @@ export class WorkspaceDirectory { // - priority unmasking: when the winning bucket transitions (e.g. a // higher-priority bucket cleared), the new entry time is "now"; // - same-bucket emits reuse the previous entered-at; - // - empty workspaces (no contributing agents) get `statusEnteredAt: null`. + // - empty workspaces that never had contributing agents get + // `statusEnteredAt: null`. + // - when archived agents leave a previously active workspace empty, keep + // the previous done timestamp or stamp the transition to done now. private resolveStatusEnteredAt(params: { workspaceId: string; winningBucket: WorkspaceStateBucket; @@ -294,16 +297,15 @@ export class WorkspaceDirectory { const { winningBucket, contributingAgents, previous, nowIso } = params; if (contributingAgents.length === 0) { - if (previous && previous.bucket !== "done") { - return { statusEnteredAt: null, recordDelete: true }; + if (!previous) { + return { statusEnteredAt: null }; } - if (previous) { - return { - statusEnteredAt: null, - recordUpdate: { bucket: "done", enteredAt: previous.enteredAt }, - }; - } - return { statusEnteredAt: null }; + + const enteredAt = previous.bucket === "done" ? previous.enteredAt : nowIso; + return { + statusEnteredAt: enteredAt, + recordUpdate: { bucket: "done", enteredAt }, + }; } if (!previous) {