diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 5a2de775c..175e0ae8e 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -35,7 +35,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId return; } - // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 // Single capability read for restore. An old daemon recreates nothing on // refresh_agent, so a gone directory would spin then flash a misleading // "couldn't restore". Surface an explicit "update your host" state instead. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 358a4c055..34db46266 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2278,7 +2278,7 @@ export const ServerInfoStatusPayloadSchema = z workspaceMultiplicity: z.boolean().optional(), // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. projectRemove: z.boolean().optional(), - // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: z.boolean().optional(), }) .optional(), diff --git a/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts b/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts index 3229b8c4c..61e1a9110 100644 --- a/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts +++ b/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts @@ -49,7 +49,11 @@ describe("backfillWorkspaceIdForLegacyAgents", () => { rmSync(home, { recursive: true, force: true }); }); - async function seedLegacyAgent(cwd: string, id: string): Promise { + async function seedLegacyAgent( + cwd: string, + id: string, + overrides?: { archivedAt?: string | null }, + ): Promise { await agentStorage.upsert({ id, provider: "codex", @@ -65,7 +69,7 @@ describe("backfillWorkspaceIdForLegacyAgents", () => { config: null, runtimeInfo: { provider: "codex", sessionId: null }, persistence: null, - archivedAt: null, + archivedAt: overrides?.archivedAt ?? null, }); } @@ -133,6 +137,44 @@ describe("backfillWorkspaceIdForLegacyAgents", () => { expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit"); }); + test("stamps archived legacy agents from archived workspace owners", async () => { + await workspaceRegistry.upsert( + workspaceRecord("/tmp/repo", "ws-archived", { + archivedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await seedLegacyAgent("/tmp/repo", "legacy-agent", { + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + const migrated = await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect(migrated).toBe(1); + expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-archived"); + }); + + test("does not stamp active legacy agents from archived workspace owners", async () => { + await workspaceRegistry.upsert( + workspaceRecord("/tmp/repo", "ws-archived", { + archivedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await seedLegacyAgent("/tmp/repo", "legacy-agent"); + + const migrated = await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect(migrated).toBe(0); + expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBeUndefined(); + }); + test("does not let the home directory own descendants", async () => { const userHome = homedir(); await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home")); diff --git a/packages/server/src/server/migrations/backfill-workspace-id.migration.ts b/packages/server/src/server/migrations/backfill-workspace-id.migration.ts index de9fc66d7..5451be282 100644 --- a/packages/server/src/server/migrations/backfill-workspace-id.migration.ts +++ b/packages/server/src/server/migrations/backfill-workspace-id.migration.ts @@ -18,22 +18,27 @@ import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-r // Picks the workspace that owned `cwd` for a legacy, unstamped agent record. // Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the // deepest enclosing workspace directory, never letting the home directory own -// descendants. Used only by the one-time backfill below. +// descendants. Live records only consider live workspaces; archived records can +// resolve to archived workspaces so History/restore retains legacy ownership. +// Used only by the one-time backfill below. function resolveLegacyWorkspaceOwner( cwd: string, workspaces: Iterable, + options?: { includeArchived?: boolean }, ): string | null { const normalizedCwd = resolve(cwd); const userHome = resolve(homedir()); - const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt); - const exactMatches = activeWorkspaces.filter( + const candidateWorkspaces = Array.from(workspaces).filter( + (workspace) => options?.includeArchived === true || !workspace.archivedAt, + ); + const exactMatches = candidateWorkspaces.filter( (workspace) => resolve(workspace.cwd) === normalizedCwd, ); if (exactMatches.length > 0) { return oldestWorkspace(exactMatches).workspaceId; } - const prefixMatches = activeWorkspaces.filter((workspace) => { + const prefixMatches = candidateWorkspaces.filter((workspace) => { const workspaceCwd = resolve(workspace.cwd); if (workspaceCwd === userHome) { return false; @@ -72,7 +77,9 @@ export async function backfillWorkspaceIdForLegacyAgents(options: { continue; } - const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords); + const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords, { + includeArchived: record.archivedAt != null, + }); if (!workspaceId) { continue; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 47427dd67..f49131411 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -988,12 +988,7 @@ export class Session { hasBinaryChannel: () => this.onBinaryMessage !== null, isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, - listTerminalWorkspaceRoots: async () => { - const workspaces = await this.workspaceRegistry.list(); - return workspaces - .filter((workspace) => !workspace.archivedAt) - .map((workspace) => workspace.cwd); - }, + listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), clientSupportsWrapReflow: () => this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), getClientBufferedAmount: () => this.getTransportBufferedAmount(), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9bcbb933d..48711dc65 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1159,7 +1159,7 @@ export class VoiceAssistantWebSocketServer { workspaceMultiplicity: true, // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. projectRemove: true, - // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: true, }, }; diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index a54421ad9..3c6dee01b 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -150,12 +150,17 @@ describe("terminal-session-controller restore", () => { }); }); -function listSession(input: { id: string; name: string; cwd: string }): TerminalSession { +function listSession(input: { + id: string; + name: string; + cwd: string; + workspaceId?: string; +}): TerminalSession { return { id: input.id, name: input.name, cwd: input.cwd, - workspaceId: "ws-test", + workspaceId: input.workspaceId ?? "ws-test", send: vi.fn(), subscribe: () => vi.fn(), onExit: () => vi.fn(), @@ -176,6 +181,86 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal }; } +describe("terminal-session-controller legacy terminal creation", () => { + test("resolves a missing workspaceId from the active workspace root", async () => { + const rootCwd = "/work/repo"; + const appCwd = "/work/repo/packages/app"; + const terminalCwd = "/work/repo/packages/app/src"; + const outboundMessages: SessionOutboundMessage[] = []; + const createTerminal = vi.fn( + async (options: Parameters[0]) => + listSession({ + id: "term-1", + name: options.name ?? "Terminal 1", + cwd: options.cwd, + workspaceId: options.workspaceId, + }), + ); + const terminalManager: TerminalManager = { + getTerminals: vi.fn(), + createTerminal, + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [ + { workspaceId: "ws-root", cwd: rootCwd }, + { workspaceId: "ws-app", cwd: appCwd }, + ], + }); + + await controller.dispatch({ + type: "create_terminal_request", + cwd: terminalCwd, + name: "App Shell", + requestId: "req-1", + }); + + expect(createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: terminalCwd, + workspaceId: "ws-app", + name: "App Shell", + }), + ); + expect(outboundMessages).toEqual([ + { + type: "create_terminal_response", + payload: { + terminal: { + id: "term-1", + name: "App Shell", + cwd: terminalCwd, + workspaceId: "ws-app", + activity: null, + }, + error: null, + requestId: "req-1", + }, + }, + ]); + }); +}); + async function flushMicrotasks(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 74556965f..dd6c54927 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -68,6 +68,7 @@ export interface TerminalSessionControllerOptions { hasBinaryChannel: () => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; + listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; // Whether the connected client can reflow restored snapshots. When true the // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them @@ -83,6 +84,11 @@ export interface TerminalSessionControllerOptions { getClientBufferedAmount?: () => number | null; } +interface TerminalWorkspaceRef { + workspaceId: string; + cwd: string; +} + export interface TerminalSessionControllerMetrics { directorySubscriptionCount: number; streamSubscriptionCount: number; @@ -120,6 +126,7 @@ export class TerminalSessionController { private readonly hasBinaryChannel: () => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly sessionLogger: pino.Logger; + private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; private readonly clientSupportsWrapReflow: () => boolean; private readonly getClientBufferedAmount: () => number | null; @@ -145,7 +152,10 @@ export class TerminalSessionController { this.hasBinaryChannel = options.hasBinaryChannel; this.isPathWithinRoot = options.isPathWithinRoot; this.sessionLogger = options.sessionLogger; - this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => []); + this.listTerminalWorkspaceRefs = options.listTerminalWorkspaceRefs ?? (async () => []); + this.listTerminalWorkspaceRoots = + options.listTerminalWorkspaceRoots ?? + (async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd)); this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false); this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0); } @@ -520,7 +530,8 @@ export class TerminalSessionController { return; } - if (!msg.workspaceId) { + const workspaceId = msg.workspaceId ?? (await this.resolveLegacyTerminalWorkspaceId(msg.cwd)); + if (!workspaceId) { this.emit({ type: "create_terminal_response", payload: { @@ -534,7 +545,7 @@ export class TerminalSessionController { const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, - workspaceId: msg.workspaceId, + workspaceId, name: msg.name, command: msg.command, args: msg.args, @@ -568,6 +579,31 @@ export class TerminalSessionController { } } + private async resolveLegacyTerminalWorkspaceId(cwd: string): Promise { + const workspaceRefs = await this.listTerminalWorkspaceRefs(); + if (workspaceRefs.length === 0) { + return null; + } + + const exactMatch = workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, cwd)); + if (exactMatch) { + return exactMatch.workspaceId; + } + + const ownerRoot = this.resolveTerminalOwnerRoot( + cwd, + workspaceRefs.map((workspace) => workspace.cwd), + ); + if (!ownerRoot) { + return null; + } + + return ( + workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, ownerRoot))?.workspaceId ?? + null + ); + } + private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise { const respond = (success: boolean, error: string | null): void => { this.emit({