From d37262be569f7c2de28bb789a2fd40a5de5e74ae Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 13:25:24 +0700 Subject: [PATCH] Keep workspace updates independent of archived history (#8735) * perf(server): scope workspace updates to indexed records * fix(server): preserve parent context in scoped workspace queries * test(server): update remaining scoped store adapters * fix(server): preserve archived exact paths in archive requests * fix(server): prefer active exact archive targets --- .../src/server/agent/agent-manager.test.ts | 8 +- .../server/src/server/agent/agent-manager.ts | 5 +- .../src/server/agent/agent-storage.test.ts | 123 ++++- .../server/src/server/agent/agent-storage.ts | 149 ++++- .../server/src/server/agent/directory.test.ts | 27 +- packages/server/src/server/agent/directory.ts | 96 +++- .../src/server/agent/import-sessions.test.ts | 24 +- .../src/server/agent/import-sessions.ts | 7 +- .../src/server/agent/mcp-server.test.ts | 38 +- .../agent/restart-recovery/index.test.ts | 2 +- .../server/agent/restart-recovery/index.ts | 6 +- .../src/server/agent/tools/paseo-tools.ts | 4 +- .../bootstrap-provider-availability.test.ts | 2 +- .../server/src/server/bootstrap.smoke.test.ts | 6 +- packages/server/src/server/bootstrap.ts | 12 +- .../hub/test-utils/relationship-harness.ts | 11 +- .../backfill-workspace-id.migration.ts | 6 +- .../src/server/paseo-worktree-service.test.ts | 3 +- .../server/src/server/persistence-hooks.ts | 2 +- .../resolve-workspace-id-for-path.test.ts | 24 + .../server/resolve-workspace-id-for-path.ts | 7 +- .../src/server/schedule/service.test.ts | 12 +- packages/server/src/server/session.test.ts | 4 +- packages/server/src/server/session.ts | 56 +- .../session.workspace-git-watch.test.ts | 9 +- ...on.workspace-resolution-invariants.test.ts | 9 +- .../src/server/session.workspaces.test.ts | 513 ++++++++++++++---- .../workspace-provisioning-service.test.ts | 33 +- .../workspace-provisioning-service.ts | 10 +- .../reconciliation-registries.ts | 3 +- .../src/server/test-utils/session-stubs.ts | 13 + .../server/src/server/websocket-server.ts | 3 +- .../server/src/server/wire-compat.test.ts | 19 +- .../server/workspace-archive-service.test.ts | 5 +- .../src/server/workspace-directory.test.ts | 182 ++++++- .../server/src/server/workspace-directory.ts | 27 +- ...rkspace-reconciliation-observation.test.ts | 4 +- .../workspace-reconciliation-service.ts | 6 +- .../workspace-registry-bootstrap.test.ts | 16 +- .../server/workspace-registry-bootstrap.ts | 8 +- .../src/server/workspace-registry.test.ts | 2 +- .../server/src/server/workspace-registry.ts | 22 +- .../src/server/worktree-session.test.ts | 7 +- 43 files changed, 1210 insertions(+), 315 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index a6d1f9d759..57e5b0df20 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4092,7 +4092,7 @@ test("createAgent rejects a disabled provider without creating a session", async ), ).rejects.toThrow("Provider 'codex' is disabled"); expect(disabledClient.createSessionCalls).toBe(0); - expect(await storage.list()).toHaveLength(0); + expect(await storage.listAll()).toHaveLength(0); }); test("updateProviderRegistry re-enables a previously disabled provider", async () => { @@ -8273,7 +8273,7 @@ test("archiveAgent closes an internal agent without writing a record", async () expect(Number.isNaN(Date.parse(archivedAt))).toBe(false); expect(manager.getAgent(internalAgentId)).toBeNull(); expect(await storage.get(internalAgentId)).toBeNull(); - expect(await storage.list()).toEqual([]); + expect(await storage.listAll()).toEqual([]); expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({ record: expect.objectContaining({ id: internalAgentId, @@ -9379,8 +9379,8 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async ( class ChildOpensAfterCascadeListStorage extends AgentStorage { childId: string | null = null; - override async list(): Promise { - const records = await super.list(); + override async listActive(): Promise { + const records = await super.listAll(); const parentIsArchived = records.some( (record) => record.id !== this.childId && Boolean(record.archivedAt), ); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fa066989ff..32fa4ca4a6 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2065,7 +2065,7 @@ export class AgentManager { return; } const parentAgentId = parent.id; - const records = await registry.list(); + const records = await registry.listActive(); for (const record of records) { if (record.archivedAt) { continue; @@ -2531,7 +2531,8 @@ export class AgentManager { async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise { const registry = this.requireRegistry(); - const records = await registry.list(); + // Restoring an archived provider session is an explicit recovery operation. + const records = await registry.listAll(); const matched = records.find( (record) => record.persistence?.provider === handle.provider && diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 723f612085..c87b812761 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { promises as fs } from "node:fs"; +import { Session as InspectorSession } from "node:inspector/promises"; +import type { HeapProfiler } from "node:inspector"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentStorage } from "./agent-storage.js"; @@ -148,6 +150,101 @@ describe("AgentStorage", () => { rmSync(tmpDir, { recursive: true, force: true }); }); + test("workspace and active queries follow updates, removal, and reload", async () => { + const base = { + provider: "codex", + cwd: tmpDir, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + labels: {}, + }; + await storage.upsert({ + ...base, + id: "a", + workspaceId: "one", + lastUserMessageAt: base.createdAt, + }); + await storage.upsert({ ...base, id: "b", workspaceId: "two", archivedAt: base.createdAt }); + expect((await storage.listActive()).map((r) => r.id)).toEqual(["a"]); + await storage.upsert({ + ...base, + id: "a", + workspaceId: "two", + lastUserMessageAt: base.updatedAt, + }); + expect(await storage.listByWorkspaces(["one"])).toEqual([]); + expect((await storage.listByWorkspaces(["two"])).map((r) => r.id)).toEqual(["a"]); + expect( + (await storage.listByWorkspaces(["two"], { includeArchived: true })).map((r) => r.id).sort(), + ).toEqual(["a", "b"]); + expect(await storage.lastUserMessageAtByWorkspace(["one", "two"])).toEqual( + new Map([["two", base.createdAt]]), + ); + await storage.flush(); + const reloaded = new AgentStorage(storagePath, createTestLogger()); + expect((await reloaded.listActive()).map((r) => r.id)).toEqual(["a"]); + expect( + await reloaded.lastUserMessageAtByWorkspace(["two"], { excludeAgentIds: ["a"] }), + ).toEqual(new Map()); + await reloaded.remove("a"); + expect(await reloaded.listActive()).toEqual([]); + expect(await reloaded.lastUserMessageAtByWorkspace(["two"])).toEqual(new Map()); + }); + + test("single-agent lookups keep allocations bounded with a large stored history", async () => { + await fs.mkdir(storagePath, { recursive: true }); + const record = { + id: "history-0", + provider: "codex", + cwd: tmpDir, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + labels: {}, + lastStatus: "idle", + }; + for (let offset = 0; offset < 2048; offset += 64) { + await Promise.all( + Array.from({ length: 64 }, (_, index) => { + const id = `history-${offset + index}`; + return fs.writeFile( + path.join(storagePath, `${id}.json`), + JSON.stringify({ ...record, id }), + ); + }), + ); + } + await storage.initialize(); + expect(await storage.listAll()).toHaveLength(2048); + const expected = await storage.get(record.id); + expect(expected).toMatchObject(record); + + const inspector = new InspectorSession(); + inspector.connect(); + let allocatedBytes = 0; + let matches = 0; + try { + await inspector.post("HeapProfiler.startSampling", { + samplingInterval: 16384, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + for (let index = 0; index < 1000; index++) { + if ((await storage.get(record.id)) === expected) matches++; + } + const { profile } = await inspector.post("HeapProfiler.stopSampling"); + const sum = (node: HeapProfiler.SamplingHeapProfileNode): number => + node.selfSize + node.children.reduce((bytes, child) => bytes + sum(child), 0); + allocatedBytes = sum(profile.head); + } finally { + inspector.disconnect(); + } + + expect(matches).toBe(1000); + // Copying this history on every lookup allocates >16 MiB. Allow generous + // sampling/runtime overhead without relying on the host's execution speed. + expect(allocatedBytes).toBeLessThan(4 * 1024 * 1024); + }); + test("applySnapshot persists configs and snapshot metadata", async () => { await storage.applySnapshot( createManagedAgent({ @@ -172,7 +269,7 @@ describe("AgentStorage", () => { }), ); - const records = await storage.list(); + const records = await storage.listAll(); expect(records).toHaveLength(1); const [record] = records; expect(record.provider).toBe("claude"); @@ -189,8 +286,8 @@ describe("AgentStorage", () => { expect(record.lastModeId).toBe("coding"); expect(record.lastStatus).toBe("idle"); - const reloaded = new AgentStorage(storagePath, logger); - const [persisted] = await reloaded.list(); + const reloaded = new AgentStorage(storagePath, createTestLogger()); + const [persisted] = await reloaded.listAll(); expect(persisted.cwd).toBe("/tmp/project"); expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] }); }); @@ -210,7 +307,7 @@ describe("AgentStorage", () => { const record = await storage.get("agent-feature-values"); expect(record?.config?.featureValues).toEqual({ fast_mode: true }); - const reloaded = new AgentStorage(storagePath, logger); + const reloaded = new AgentStorage(storagePath, createTestLogger()); const persisted = await reloaded.get("agent-feature-values"); expect(persisted?.config?.featureValues).toEqual({ fast_mode: true }); expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true }); @@ -223,7 +320,7 @@ describe("AgentStorage", () => { }), ); - const reloaded = new AgentStorage(storagePath, logger); + const reloaded = new AgentStorage(storagePath, createTestLogger()); const persisted = await reloaded.get("agent-no-feature-values"); expect(persisted?.config?.featureValues).toBeUndefined(); expect(buildSessionConfig(persisted!).featureValues).toBeUndefined(); @@ -312,7 +409,7 @@ describe("AgentStorage", () => { const current = await storage.get("agent-2"); expect(current?.title).toBe("Fix Login Bug"); - const reloaded = new AgentStorage(storagePath, logger); + const reloaded = new AgentStorage(storagePath, createTestLogger()); const persisted = await reloaded.get("agent-2"); expect(persisted?.title).toBe("Fix Login Bug"); }); @@ -415,7 +512,7 @@ describe("AgentStorage", () => { ); // Registry should return all agents - filtering is done at the manager level - const records = await storage.list(); + const records = await storage.listAll(); expect(records).toHaveLength(2); }); @@ -486,12 +583,12 @@ describe("AgentStorage", () => { ); // Reload the registry from disk - const reloaded = new AgentStorage(storagePath, logger); + const reloaded = new AgentStorage(storagePath, createTestLogger()); const record = await reloaded.get("internal-agent"); expect(record?.internal).toBe(true); // Registry returns all agents - filtering happens at manager level - const records = await reloaded.list(); + const records = await reloaded.listAll(); expect(records).toHaveLength(1); expect(records[0]?.internal).toBe(true); }); @@ -543,8 +640,8 @@ describe("AgentStorage", () => { ); // Force a reload so the registry has to discover from disk (and may choose either copy). - const reloaded = new AgentStorage(storagePath, logger); - const before = await reloaded.list(); + const reloaded = new AgentStorage(storagePath, createTestLogger()); + const before = await reloaded.listAll(); expect(before.map((r) => r.id)).toContain(agentId); await reloaded.remove(agentId); @@ -571,8 +668,8 @@ describe("AgentStorage", () => { expect(await hasAnyRecordFile()).toBe(false); - const afterReload = new AgentStorage(storagePath, logger); - const after = await afterReload.list(); + const afterReload = new AgentStorage(storagePath, createTestLogger()); + const after = await afterReload.listAll(); expect(after.some((r) => r.id === agentId)).toBe(false); }); }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6b1e8075b3..41ad500969 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -105,9 +105,14 @@ export class AgentStorage { private deleting: Set = new Set(); private daemonAgentIdsByExecution: Map = new Map(); private daemonExecutionKeysByAgentId: Map = new Map(); + private readonly idsByWorkspace = new Map>(); + private readonly workspaceById = new Map(); + private readonly activeIds = new Set(); + private readonly promptTimeByAgent = new Map(); + private readonly promptTimes = new Map(); private loaded = false; private baseDir: string; - private loadPromise: Promise | null = null; + private loadPromise: Promise | null = null; private logger: Logger; constructor(baseDir: string, logger: Logger) { @@ -119,11 +124,119 @@ export class AgentStorage { await this.load(); } - async list(): Promise { + async listAll(): Promise { await this.load(); return Array.from(this.cache.values()); } + async listActive(options?: { includeArchived?: boolean }): Promise { + await this.load(); + return Array.from( + options?.includeArchived ? this.cache.keys() : this.activeIds, + (id) => this.cache.get(id)!, + ); + } + + async listByWorkspaces( + workspaceIds: Iterable, + options?: { includeArchived?: boolean }, + ): Promise { + await this.load(); + const records: StoredAgentRecord[] = []; + for (const workspaceId of new Set(workspaceIds)) { + for (const id of this.idsByWorkspace.get(workspaceId) ?? []) { + const record = this.cache.get(id)!; + if (options?.includeArchived || !record.archivedAt) records.push(record); + } + } + return records; + } + + async lastUserMessageAtByWorkspace( + workspaceIds?: Iterable, + options?: { excludeAgentIds?: Iterable }, + ): Promise> { + await this.load(); + const times = new Map(); + const excluded = new Set(options?.excludeAgentIds); + const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id))); + for (const id of workspaceIds ?? this.promptTimes.keys()) { + let at = this.promptTimes.get(id); + if (affected.has(id)) { + at = undefined; + for (const agentId of this.idsByWorkspace.get(id) ?? []) { + const record = this.cache.get(agentId)!; + if ( + !excluded.has(agentId) && + !record.internal && + !record.background && + record.lastUserMessageAt && + record.lastUserMessageAt > (at ?? "") + ) { + at = record.lastUserMessageAt; + } + } + } + if (at) times.set(id, at); + } + return times; + } + + private indexWorkspace(record: StoredAgentRecord): void { + const previous = this.workspaceById.get(record.id); + if (previous) this.idsByWorkspace.get(previous)?.delete(record.id); + this.workspaceById.delete(record.id); + this.activeIds.delete(record.id); + if (!record.archivedAt) this.activeIds.add(record.id); + if (record.workspaceId) { + const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set(); + ids.add(record.id); + this.idsByWorkspace.set(record.workspaceId, ids); + this.workspaceById.set(record.id, record.workspaceId); + } + this.indexPromptTime(record, previous); + } + + private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void { + const previousTime = this.promptTimeByAgent.get(record.id); + const nextTime = + !record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined; + if (nextTime) this.promptTimeByAgent.set(record.id, nextTime); + else this.promptTimeByAgent.delete(record.id); + if (previous === record.workspaceId && previousTime === nextTime) return; + if ( + previous && + previousTime && + this.promptTimes.get(previous) === previousTime && + (previous !== record.workspaceId || !nextTime || nextTime < previousTime) + ) { + this.refreshPromptTime(previous); + } + if ( + record.workspaceId && + nextTime && + nextTime > (this.promptTimes.get(record.workspaceId) ?? "") + ) { + this.promptTimes.set(record.workspaceId, nextTime); + } + } + + private refreshPromptTime(workspaceId: string): void { + this.promptTimes.delete(workspaceId); + let latest = ""; + for (const id of this.idsByWorkspace.get(workspaceId) ?? []) { + const record = this.cache.get(id)!; + if ( + !record.internal && + !record.background && + record.lastUserMessageAt && + record.lastUserMessageAt > latest + ) + latest = record.lastUserMessageAt; + } + if (latest) this.promptTimes.set(workspaceId, latest); + } + async get(agentId: string): Promise { await this.load(); return this.cache.get(agentId) ?? null; @@ -143,8 +256,7 @@ export class AgentStorage { } async listByWorkspace(workspaceId: string): Promise { - await this.load(); - return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId); + return this.listByWorkspaces([workspaceId], { includeArchived: true }); } async findByDaemonExecution(owner: DaemonAgentOwner): Promise { @@ -234,6 +346,14 @@ export class AgentStorage { }), ); + const workspaceId = this.workspaceById.get(agentId); + this.workspaceById.delete(agentId); + this.promptTimeByAgent.delete(agentId); + this.activeIds.delete(agentId); + if (workspaceId) { + this.idsByWorkspace.get(workspaceId)?.delete(agentId); + this.refreshPromptTime(workspaceId); + } this.cache.delete(agentId); this.removeOwnerIndex(agentId); this.pathById.delete(agentId); @@ -286,9 +406,9 @@ export class AgentStorage { await Promise.allSettled(writes); } - private async load(): Promise { + private async load(): Promise { if (this.loaded) { - return Array.from(this.cache.values()); + return; } if (!this.loadPromise) { @@ -298,7 +418,7 @@ export class AgentStorage { return this.loadPromise; } - private async doLoad(): Promise { + private async doLoad(): Promise { this.cache.clear(); this.pathById.clear(); this.pathsById.clear(); @@ -306,28 +426,25 @@ export class AgentStorage { this.daemonExecutionKeysByAgentId.clear(); try { - const records = await this.scanDisk(); + await this.scanDisk(); this.loaded = true; - return records; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.loaded = true; - return []; + return; } this.logger.error({ err: error }, "Failed to load agents"); this.loaded = true; - return []; } } - private async scanDisk(): Promise { - const records: StoredAgentRecord[] = []; + private async scanDisk(): Promise { let entries: Dirent[] = []; try { entries = await fs.readdir(this.baseDir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return []; + return; } throw error; } @@ -364,14 +481,11 @@ export class AgentStorage { for (const item of loaded) { if (!item) continue; const { record, filePath } = item; - records.push(record); this.cache.set(record.id, record); this.indexOwner(record); this.pathById.set(record.id, filePath); this.addIndexedPath(record.id, filePath); } - - return records; } private async readRecordFile(filePath: string): Promise { @@ -408,6 +522,7 @@ export class AgentStorage { } private indexOwner(record: StoredAgentRecord): void { + this.indexWorkspace(record); this.removeOwnerIndex(record.id); if (record.owner?.kind === "daemon") { const key = daemonExecutionKey(record.owner); diff --git a/packages/server/src/server/agent/directory.test.ts b/packages/server/src/server/agent/directory.test.ts index 58bac6c183..e5767de4f7 100644 --- a/packages/server/src/server/agent/directory.test.ts +++ b/packages/server/src/server/agent/directory.test.ts @@ -1,3 +1,4 @@ +import { resolveWorkspaceRootAgent } from "../workspace-directory.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -50,7 +51,6 @@ beforeEach(async () => { manager: { listAgents: () => [] }, workspaces: { get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null, - list: async () => workspaces, }, projectLive: () => { throw new Error("No live agents in this suite"); @@ -112,3 +112,28 @@ test("prompt recency ignores background agents", async () => { new Map([["open", "2026-03-02T00:00:00.000Z"]]), ); }); + +test("scoped status queries retain cross-workspace parent context", async () => { + const base = { provider: "codex", cwd: home, createdAt: NOW, updatedAt: NOW }; + await storage.upsert({ ...base, id: "parent", workspaceId: "open" }); + await storage.upsert({ + ...base, + id: "child", + workspaceId: "hidden", + labels: { "paseo.parent-agent-id": "parent" }, + lastStatus: "running", + }); + const agents = await directory.list({ + workspaceIds: ["hidden"], + includeBackground: true, + includeAncestors: true, + }); + const child = agents.find((agent) => agent.id === "child")!; + expect( + resolveWorkspaceRootAgent(child, new Map(agents.map((agent) => [agent.id, agent])))?.id, + ).toBe("child"); + expect(child.status).toBe("running"); + expect( + (await directory.list({ workspaceIds: ["hidden"], includeBackground: true })).map((a) => a.id), + ).not.toContain("parent"); +}); diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index e7e1edd9e9..1f3b88df67 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -1,3 +1,4 @@ +import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { AgentSnapshotPayload } from "../messages.js"; import type { WorkspaceRegistry } from "../workspace-registry.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; @@ -26,8 +27,11 @@ export class AgentDirectory { constructor( private readonly deps: { manager: Pick; - storage: Pick; - workspaces: Pick; + storage: Pick< + AgentStorage, + "get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace" + >; + workspaces: Pick; projectLive(agent: ManagedAgent): Promise; projectStored(record: StoredAgentRecord): AgentSnapshotPayload; isProviderVisible(provider: string): boolean; @@ -36,22 +40,22 @@ export class AgentDirectory { ) {} /** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */ - async workspaceLastUserMessageTimes(): Promise> { + async workspaceLastUserMessageTimes( + workspaceIds?: Iterable, + ): Promise> { + const requested = workspaceIds ? new Set(workspaceIds) : undefined; const live = this.deps.manager.listAgents(); - const liveIds = new Set(live.map((agent) => agent.id)); - const times = new Map(); - const record = (workspaceId: string | undefined, at: string | null | undefined) => { - if (!workspaceId || !at) return; - const previous = times.get(workspaceId); - if (!previous || at > previous) times.set(workspaceId, at); - }; - for (const agent of await this.deps.storage.list()) { - if (!agent.internal && !agent.background && !liveIds.has(agent.id)) - record(agent.workspaceId, agent.lastUserMessageAt); - } + const times = new Map( + await this.deps.storage.lastUserMessageAtByWorkspace(requested, { + excludeAgentIds: live.map((agent) => agent.id), + }), + ); for (const agent of live) { - if (!agent.internal && !agent.background) - record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); + if (agent.internal || agent.background || !agent.workspaceId || !agent.lastUserMessageAt) + continue; + if (requested && !requested.has(agent.workspaceId)) continue; + const at = agent.lastUserMessageAt.toISOString(); + if (at > (times.get(agent.workspaceId) ?? "")) times.set(agent.workspaceId, at); } return times; } @@ -64,23 +68,67 @@ export class AgentDirectory { return admits(agent, workspace?.background === true, visibility); } + private async appendAncestors( + live: ManagedAgent[], + selectedLive: ManagedAgent[], + records: StoredAgentRecord[], + ): Promise { + const selectedIds = new Set([...selectedLive, ...records].map((agent) => agent.id)); + const liveById = new Map(live.map((agent) => [agent.id, agent])); + const pending = [...selectedLive, ...records]; + for (const agent of pending) { + const parentId = getParentAgentIdFromLabels(agent.labels); + if (!parentId || selectedIds.has(parentId)) continue; + selectedIds.add(parentId); + const liveParent = liveById.get(parentId); + if (liveParent) { + pending.push(liveParent); + selectedLive.push(liveParent); + } else { + const storedParent = await this.deps.storage.get(parentId); + if (storedParent) { + pending.push(storedParent); + records.push(storedParent); + } + } + } + } + async list(filter?: { + workspaceIds?: Iterable; + /** Include parent chains by ID for workspace root classification. */ + includeAncestors?: boolean; labels?: Record; includeArchived?: boolean; includeBackground?: BackgroundVisibility; includeUnavailablePersisted?: boolean; }): Promise { + const requested = filter?.workspaceIds ? new Set(filter.workspaceIds) : null; const live = this.deps.manager.listAgents(); const liveIds = new Set(live.map((agent) => agent.id)); const labels = Object.entries(filter?.labels ?? {}); const visibility = filter?.includeBackground ?? false; - const backgroundWorkspaceIds = new Set( - visibility === true - ? [] - : (await this.deps.workspaces.list()) - .filter((workspace) => workspace.background) - .map((workspace) => workspace.workspaceId), + const records = requested + ? await this.deps.storage.listByWorkspaces(requested, filter) + : await this.deps.storage.listActive(filter); + const selectedLive = requested + ? live.filter((agent) => requested.has(agent.workspaceId ?? "")) + : live; + if (filter?.includeAncestors && requested) { + await this.appendAncestors(live, selectedLive, records); + } + + const workspaceIds = new Set( + [...selectedLive, ...records].flatMap((agent) => + agent.workspaceId ? [agent.workspaceId] : [], + ), ); + const backgroundWorkspaceIds = new Set(); + if (visibility !== true) { + for (const id of workspaceIds) { + if ((await this.deps.workspaces.get(id))?.background) backgroundWorkspaceIds.add(id); + } + } const matches = ( agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record }, ) => @@ -91,7 +139,7 @@ export class AgentDirectory { visibility, ) && labels.every(([key, value]) => agent.labels?.[key] === value); - const stored = (await this.deps.storage.list()) + const stored = records .filter((record) => !liveIds.has(record.id) && matches(record)) .filter( (record) => @@ -99,7 +147,7 @@ export class AgentDirectory { ) .map((record) => this.deps.projectStored(record)); const active = await Promise.all( - live.filter(matches).map((agent) => this.deps.projectLive(agent)), + selectedLive.filter(matches).map((agent) => this.deps.projectLive(agent)), ); return [...active, ...stored] .filter(matches) diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 9190e6942b..d2d97b12b6 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import listImportableSessions, } satisfies Pick; const agentStorage = { - list: async () => [ + listAll: async () => [ { provider: "codex", persistence: { @@ -231,7 +231,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import }, } as StoredAgentRecord, ], - } satisfies Pick; + } satisfies Pick; const result = await listImportableProviderSessions({ request: makeRequest({ @@ -303,7 +303,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th listImportableSessions, }, agentStorage: { - list: async () => [ + listAll: async () => [ { provider: "claude", persistence: { provider: "claude", sessionId: "already-imported" }, @@ -360,7 +360,7 @@ test("listImportableProviderSessions requests a bounded deep scan for search res const result = await listImportableProviderSessions({ request: makeRequest({ query: "INVOICE", limit: 10 }), agentManager: { listAgents: () => [], listImportableSessions }, - agentStorage: { list: async () => [] }, + agentStorage: { listAll: async () => [] }, providerSnapshotManager: { getProviderLabel: (provider) => provider }, }); @@ -397,7 +397,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo listImportableSessions: async () => makeImportableSessionsResult([archivedSession]), }, agentStorage: { - list: async () => [ + listAll: async () => [ { provider: "claude", archivedAt: "2026-04-30T12:01:00.000Z", @@ -441,7 +441,7 @@ test("listImportableProviderSessions includes an archived provider session still listImportableSessions: async () => makeImportableSessionsResult([archivedSession]), }, agentStorage: { - list: async () => [ + listAll: async () => [ { id: agentId, provider: "claude", @@ -489,8 +489,8 @@ test("listImportableProviderSessions filters out metadata generation sessions", listImportableSessions: async () => makeImportableSessionsResult(sessions), } satisfies Pick, agentStorage: { - list: async () => [], - } satisfies Pick, + listAll: async () => [], + } satisfies Pick, providerSnapshotManager: { getProviderLabel: () => "Codex" }, }); @@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy ]), } satisfies Pick, agentStorage: { - list: async () => [], - } satisfies Pick, + listAll: async () => [], + } satisfies Pick, providerSnapshotManager: { getProviderLabel: () => "Pi" }, }); @@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () => listImportableSessions: async () => makeImportableSessionsResult([]), } satisfies Pick, agentStorage: { - list: async () => [], - } satisfies Pick, + listAll: async () => [], + } satisfies Pick, providerSnapshotManager: { getProviderLabel: () => "" }, }), ).rejects.toMatchObject( diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index d85c66f515..dec3c88188 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error { export interface ListImportableProviderSessionsInput { request: FetchRecentProviderSessionsRequestMessage; agentManager: Pick; - agentStorage: Pick; + agentStorage: Pick; providerSnapshotManager: Pick; } @@ -342,12 +342,13 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n async function collectImportedProviderSessions( agentManager: Pick, - agentStorage: Pick, + agentStorage: Pick, providerFilter: Set | undefined, ): Promise<{ handles: Set; count: number }> { const handles = new Set(); const sessions = new Set(); - const records = await agentStorage.list(); + // Explicit provider-history import deduplicates against archived as well as active sessions. + const records = await agentStorage.listAll(); const storedRecordsById = new Map(records.map((record) => [record.id, record])); const collect = ( diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b85a9786c3..6c463da2c1 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -292,7 +292,9 @@ function buildAgentStorageSpies() { setTitle: vi.fn().mockResolvedValue(undefined), upsert: vi.fn().mockResolvedValue(undefined), applySnapshot: vi.fn(), - list: vi.fn().mockResolvedValue([]), + listActive: vi.fn().mockResolvedValue([]), + listByWorkspaces: vi.fn().mockResolvedValue([]), + lastUserMessageAtByWorkspace: async () => new Map(), remove: vi.fn(), }; } @@ -639,7 +641,16 @@ class BoundaryAgentManagerFake { } class BoundaryAgentStorageFake { - public async list(): Promise { + public async listActive(): Promise { + return []; + } + async listByWorkspaces() { + return []; + } + async lastUserMessageAtByWorkspace() { + return new Map(); + } + async listAll() { return []; } } @@ -772,7 +783,8 @@ function createPaseoWorktreeForMcpTest(options: { initialize: async () => {}, existsOnDisk: async () => true, get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt), update: async (workspaceId, updater) => { const workspace = workspaces.get(workspaceId); if (!workspace) return null; @@ -894,7 +906,11 @@ async function createAgentMcpServer( const server = await createAgentMcpServerWithDependencies({ agentMessages: () => messages, finishSubscriptions: () => subscriptions, - workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, + workspaceRegistry: { + get: async () => null, + listActive: async () => [], + upsert: async () => {}, + }, ...options, }); serverSubscriptionRecords.set(server, records); @@ -2697,7 +2713,7 @@ describe("create_agent MCP tool", () => { providerSnapshotManager: createOpenCodeManager().manager, workspaceRegistry: { get: vi.fn(async () => workspace), - list: vi.fn(async () => [workspace]), + listActive: vi.fn(async () => [workspace]), upsert: vi.fn(async () => undefined), }, logger, @@ -2730,7 +2746,7 @@ describe("create_agent MCP tool", () => { providerSnapshotManager: createOpenCodeManager().manager, workspaceRegistry: { get: vi.fn(async () => backgroundWorkspace), - list: vi.fn(async () => [backgroundWorkspace]), + listActive: vi.fn(async () => [backgroundWorkspace]), upsert: vi.fn(async () => undefined), }, createDirectoryWorkspace: async (cwd, _title, _projectId, context) => { @@ -6326,7 +6342,7 @@ describe("agent snapshot MCP serialization", () => { createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }), createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }), ]); - spies.agentStorage.list.mockResolvedValue([ + spies.agentStorage.listActive.mockResolvedValue([ createStoredRecord({ id: "stored-in-cwd", cwd: "/tmp/workspace", @@ -6385,7 +6401,7 @@ describe("agent snapshot MCP serialization", () => { updatedAt: new Date(old), }), ]); - spies.agentStorage.list.mockResolvedValue([ + spies.agentStorage.listActive.mockResolvedValue([ createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }), createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }), createStoredRecord({ @@ -6426,7 +6442,7 @@ describe("agent snapshot MCP serialization", () => { archivedAt: new Date(now - index * 60 * 1000).toISOString(), }), ); - spies.agentStorage.list.mockResolvedValue([ + spies.agentStorage.listActive.mockResolvedValue([ ...recentArchivedRecords, createStoredRecord({ id: "old-archived", @@ -6457,7 +6473,7 @@ describe("agent snapshot MCP serialization", () => { it("returns compact list items for stored archived agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const now = new Date().toISOString(); - spies.agentStorage.list.mockResolvedValue([ + spies.agentStorage.listActive.mockResolvedValue([ createStoredRecord({ id: "stored-archived-compact", cwd: REPO_CWD, @@ -6576,7 +6592,7 @@ describe("agent snapshot MCP serialization", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const now = new Date().toISOString(); spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]); - spies.agentStorage.list.mockResolvedValue([ + spies.agentStorage.listActive.mockResolvedValue([ createStoredRecord({ id: "stored-non-archived", updatedAt: now, diff --git a/packages/server/src/server/agent/restart-recovery/index.test.ts b/packages/server/src/server/agent/restart-recovery/index.test.ts index 0b1658b47d..77adf359c4 100644 --- a/packages/server/src/server/agent/restart-recovery/index.test.ts +++ b/packages/server/src/server/agent/restart-recovery/index.test.ts @@ -98,7 +98,7 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an enabled = true; recovery.resumePending(); expect(dispatched).toEqual([]); - expect((await storage.list()).every((record) => !record.resumeOnStartup)).toBe(true); + expect((await storage.listAll()).every((record) => !record.resumeOnStartup)).toBe(true); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/server/src/server/agent/restart-recovery/index.ts b/packages/server/src/server/agent/restart-recovery/index.ts index dc4e7d90c7..fe558b0219 100644 --- a/packages/server/src/server/agent/restart-recovery/index.ts +++ b/packages/server/src/server/agent/restart-recovery/index.ts @@ -21,7 +21,8 @@ export function createRestartRecovery(deps: Dependencies) { return { /** Choose candidates before anything else reads or repairs persisted running status. */ async prepareStartup() { - for (const record of await deps.storage.list()) { + // Startup recovery repairs persisted running status once before agents are restored. + for (const record of await deps.storage.listAll()) { const pending = deps.settings().enabled && (record.resumeOnStartup === true || record.lastStatus === "running") && @@ -45,7 +46,8 @@ export function createRestartRecovery(deps: Dependencies) { }, async captureRunningAgents() { await deps.storage.flush(); - for (const record of await deps.storage.list()) { + // Shutdown recovery captures running state once before the daemon exits. + for (const record of await deps.storage.listAll()) { if (record.lastStatus === "running" && (await eligible(record))) await deps.storage.upsert({ ...record, resumeOnStartup: deps.settings().enabled }); } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fe2d4187b9..7b33d2e250 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -138,7 +138,7 @@ export interface PaseoToolHostDependencies { listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - workspaceRegistry?: Pick; + workspaceRegistry?: Pick; projectRegistry?: Pick; createDirectoryWorkspace?: ( cwd: string, @@ -1547,7 +1547,7 @@ function createToolCatalog( if (!options.workspaceRegistry) { throw new Error("Workspace registry is not configured"); } - const workspaces = (await options.workspaceRegistry.list()) + const workspaces = (await options.workspaceRegistry.listActive()) .filter( (workspace) => !workspace.archivedAt && (includeBackground || !workspace.background), ) diff --git a/packages/server/src/server/bootstrap-provider-availability.test.ts b/packages/server/src/server/bootstrap-provider-availability.test.ts index be431e0dc5..1a0deb5012 100644 --- a/packages/server/src/server/bootstrap-provider-availability.test.ts +++ b/packages/server/src/server/bootstrap-provider-availability.test.ts @@ -95,7 +95,7 @@ describe("bootstrap provider availability", () => { const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); try { - await expect(daemon.agentStorage.list()).resolves.toHaveLength(1); + await expect(daemon.agentStorage.listAll()).resolves.toHaveLength(1); await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({ provider: "codex", available: false, diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ac045741f1..81c621f7fd 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1139,11 +1139,13 @@ test("disabled recovery closes stale active records without resuming sessions", : record.lastStatus, })); expect( - (await handle.daemon.agentStorage.list()).sort((a, b) => a.id.localeCompare(b.id)), + (await handle.daemon.agentStorage.listAll()).sort((a, b) => a.id.localeCompare(b.id)), ).toEqual(expected.sort((a, b) => a.id.localeCompare(b.id))); // A fresh reader proves the repaired states were written, rather than projected only in memory. expect( - (await new AgentStorage(storagePath, logger).list()).sort((a, b) => a.id.localeCompare(b.id)), + (await new AgentStorage(storagePath, logger).listAll()).sort((a, b) => + a.id.localeCompare(b.id), + ), ).toEqual(expected); expect(handle.daemon.agentManager.listAgents()).toEqual([]); expect(sessionsCreated).toBe(0); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e2c00828bc..59990f817f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -964,7 +964,7 @@ export async function createPaseoDaemon( workspaceRegistry.subscribeToMutations(async (mutation) => { if (mutation.kind === "remove" && mutation.workspace) { const cwd = path.resolve(mutation.workspace.cwd); - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listActive(); const hasActiveWorkspace = workspaces.some( (workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd, ); @@ -1114,7 +1114,8 @@ export async function createPaseoDaemon( // external path→workspace adapter, not ownership: archive-by-path requests that // arrive with a worktree path and no workspaceId (old clients / CLI). const findWorkspaceIdForCwdExternal = async (cwd: string): Promise => { - return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()); + // Explicit archive administration must prefer an archived exact match to an active ancestor. + return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.listAll()); }; const ensureWorkspaceForCreateExternal = async ( cwd: string, @@ -1137,7 +1138,7 @@ export async function createPaseoDaemon( return workspace.workspaceId; }; const listActiveWorkspacesExternal = async (): Promise => { - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listActive(); return workspaces .filter((workspace) => !workspace.archivedAt) .map((workspace) => ({ @@ -1179,7 +1180,7 @@ export async function createPaseoDaemon( return workspaceId; }; const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => { - const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd); + const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.listActive(), cwd); await emitWorkspaceUpdatesExternal(workspaceIds); }; const emitExternalSessionMessage = (message: SessionOutboundMessage) => { @@ -1465,7 +1466,8 @@ export async function createPaseoDaemon( }); logger.info({ elapsed: elapsed() }, "Schedule service initialized"); logger.info({ elapsed: elapsed() }, "Loading persisted agent registry"); - const persistedRecords = await agentStorage.list(); + // Startup loads persisted agent history once before restoring the registry. + const persistedRecords = await agentStorage.listAll(); logger.info( { elapsed: elapsed() }, `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`, diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 84726d0b02..c3abe397f9 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -840,7 +840,7 @@ export class HubRelationshipHarness { } async durableOwnedAgentIds(): Promise { - return (await this.daemon!.agentStorage.list()) + return (await this.daemon!.agentStorage.listAll()) .filter((record) => record.owner?.kind === "daemon") .map((record) => record.id); } @@ -850,7 +850,7 @@ export class HubRelationshipHarness { path.join(this.paseoHome, "agents"), pino({ level: "silent" }), ); - return (await storage.list()) + return (await storage.listAll()) .filter((record) => record.owner?.kind === "daemon") .map((record) => record.id); } @@ -1272,7 +1272,7 @@ export class HubRelationshipHarness { }); const executions = this.executionsForReconstruction(manager, storage); const replay = await executions.create(this.ownedCreateInput(executionId)); - const durableAgentCount = (await storage.list()).filter( + const durableAgentCount = (await storage.listAll()).filter( (record) => record.owner?.kind === "daemon", ).length; return { replay, durableAgentCount }; @@ -1285,8 +1285,9 @@ export class HubRelationshipHarness { pino({ level: "silent" }), ); return { - durableAgentCount: (await storage.list()).filter((record) => record.owner?.kind === "daemon") - .length, + durableAgentCount: (await storage.listAll()).filter( + (record) => record.owner?.kind === "daemon", + ).length, }; } 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 5451be2826..2ccd808cfd 100644 --- a/packages/server/src/server/migrations/backfill-workspace-id.migration.ts +++ b/packages/server/src/server/migrations/backfill-workspace-id.migration.ts @@ -68,8 +68,10 @@ export async function backfillWorkspaceIdForLegacyAgents(options: { workspaceRegistry: WorkspaceRegistry; logger: Logger; }): Promise { - const workspaceRecords = await options.workspaceRegistry.list(); - const records = await options.agentStorage.list(); + // One-time migration resolves ownership against archived workspace history. + const workspaceRecords = await options.workspaceRegistry.listAll(); + // One-time migration updates archived agents as well as active agents. + const records = await options.agentStorage.listAll(); let migrated = 0; for (const record of records) { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 1579f92d5f..dc5780ebf9 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1092,7 +1092,8 @@ function createDeps(options?: { const workspaceRegistry: WorkspaceRegistry = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => Array.from(workspaces.values()).filter((record) => !record.archivedAt), get: async (workspaceId) => workspaces.get(workspaceId) ?? null, update: async (workspaceId, updater) => { const workspace = workspaces.get(workspaceId); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 4889eab081..bbcc3c79eb 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -18,7 +18,7 @@ function getLogger(logger: LoggerLike): LoggerLike { return logger.child({ module: "persistence" }); } -type AgentStoragePersistence = Pick; +type AgentStoragePersistence = Pick; type AgentManagerStateSource = Pick; interface BuildSessionConfigOptions { diff --git a/packages/server/src/server/resolve-workspace-id-for-path.test.ts b/packages/server/src/server/resolve-workspace-id-for-path.test.ts index 162fdf1c5a..763e7d5352 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.test.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.test.ts @@ -46,6 +46,30 @@ describe("resolveWorkspaceIdForPath", () => { ).toEqual("ws-archived"); }); + test("prefers the reopened active workspace to archived history at the same path", () => { + const archived = createWorkspaceRecord("/workspace/project", "old", { + archivedAt: "2026-03-05T00:00:00.000Z", + }); + const active = createWorkspaceRecord("/workspace/project", "current"); + for (const records of [ + [archived, active], + [active, archived], + ]) { + expect(resolveWorkspaceIdForPath("/workspace/project", records)).toBe("current"); + } + }); + + test("prefers an archived exact match to an active enclosing directory", () => { + expect( + resolveWorkspaceIdForPath("/workspace/project/sub", [ + createWorkspaceRecord("/workspace/project", "parent"), + createWorkspaceRecord("/workspace/project/sub", "archived-child", { + archivedAt: "2026-03-05T00:00:00.000Z", + }), + ]), + ).toBe("archived-child"); + }); + test("resolves the deepest enclosing workspace for a subdirectory path", () => { expect( resolveWorkspaceIdForPath("/workspace/project/packages/app", [ diff --git a/packages/server/src/server/resolve-workspace-id-for-path.ts b/packages/server/src/server/resolve-workspace-id-for-path.ts index 58b7572cff..027f0b15b6 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.ts @@ -12,7 +12,7 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; // agents under a workspace — those are keyed by `workspaceId`, and git facts // derive from a workspace's OWN cwd (id → cwd). // -// Resolution: an exact directory match wins; otherwise the deepest enclosing +// Resolution: active exact match, archived exact match, then the deepest enclosing // workspace directory, never the home directory; null when nothing encloses it. export function resolveWorkspaceIdForPath( cwd: string, @@ -20,7 +20,10 @@ export function resolveWorkspaceIdForPath( ): string | null { const workspaceRecords = Array.from(workspaces); const resolvedCwd = resolve(cwd); - const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd); + const exactMatches = workspaceRecords.filter( + (workspace) => resolve(workspace.cwd) === resolvedCwd, + ); + const exactMatch = exactMatches.find((workspace) => !workspace.archivedAt) ?? exactMatches[0]; if (exactMatch) { return exactMatch.workspaceId; } diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 53ce94cbee..43b7abdf68 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -257,9 +257,9 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi agentManager, agentStorage, findWorkspaceIdForCwd: async (cwd) => - resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()), + resolveWorkspaceIdForPath(cwd, await workspaceRegistry.listAll()), listActiveWorkspaces: async () => - (await workspaceRegistry.list()) + (await workspaceRegistry.listAll()) .filter((workspace) => !workspace.archivedAt) .map((workspace) => ({ workspaceId: workspace.workspaceId, @@ -828,7 +828,7 @@ describe("ScheduleService", () => { model: "test-model", cwd: tempDir, }); - expect(await workspaceRegistry.list()).toEqual([]); + expect(await workspaceRegistry.listAll()).toEqual([]); }); test("archiveOnFinish=false local runs create one active workspace per run", async () => { @@ -885,7 +885,7 @@ describe("ScheduleService", () => { expect(firstAgent?.workspaceId).not.toBe(secondAgent?.workspaceId); expect(firstAgent?.archivedAt ?? null).toBeNull(); expect(secondAgent?.archivedAt ?? null).toBeNull(); - expect(await workspaceRegistry.list()).toEqual([ + expect(await workspaceRegistry.listAll()).toEqual([ expect.objectContaining({ workspaceId: firstAgent?.workspaceId, cwd: tempDir, @@ -1012,7 +1012,7 @@ describe("ScheduleService", () => { (service as unknown as ScheduleServiceInternals).executeSchedule(created, "run-create-fails"), ).rejects.toThrow("provider misconfigured"); - expect(await workspaceRegistry.list()).toEqual([ + expect(await workspaceRegistry.listAll()).toEqual([ expect.objectContaining({ cwd: tempDir, archivedAt: expect.any(String), @@ -1883,7 +1883,7 @@ describe("ScheduleService", () => { agentId: expect.any(String), error: expect.stringContaining("start turn exploded"), }); - const storedAgents = await agentStorage.list(); + const storedAgents = await agentStorage.listAll(); expect(storedAgents).toHaveLength(1); expect(inspected.runs[0]?.agentId).toBe(storedAgents[0]?.id); expect(storedAgents[0]).toMatchObject({ diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 4f02efad2e..1b9c2e973d 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -4239,7 +4239,7 @@ describe("session workspace descriptors", () => { ), }, messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + workspaceRegistry: { get: vi.fn(), listActive: vi.fn().mockResolvedValue([workspace]) }, projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn().mockResolvedValue(project), @@ -4319,7 +4319,7 @@ describe("session workspace descriptors", () => { ), }, messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + workspaceRegistry: { get: vi.fn(), listActive: vi.fn().mockResolvedValue([workspace]) }, projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, workspaceGitService: { getSnapshot: vi.fn(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0854ea7edd..d112419e7f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1129,7 +1129,8 @@ export class Session { getTerminalsUnavailable: () => terminalManager?.getUnavailableError?.()?.message ?? null, listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), - listWorkspaces: () => this.workspaceRegistry.list(), + // Diagnostics report both the active count and total archived history. + listWorkspaces: () => this.workspaceRegistry.listAll(), logger: this.sessionLogger, hubRelationships: options.hubRelationships, reloadConfig: () => daemonConfigStore.reload(), @@ -1260,8 +1261,10 @@ export class Session { logger: this.sessionLogger, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, - listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), - workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(), + listAgentPayloads: (workspaceIds) => + this.listAgentPayloads({ workspaceIds, includeBackground: true, includeAncestors: true }), + workspaceLastUserMessageTimes: (workspaceIds) => + this.agentDirectory.workspaceLastUserMessageTimes(workspaceIds), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), @@ -1875,11 +1878,19 @@ export class Session { if (this.isCleanedUp) { return; } - const projectWorkspaceIds = (await this.workspaceRegistry.list()) + const projectWorkspaceIds = (await this.workspaceRegistry.listActive()) .filter((workspace) => workspace.projectId === mutation.projectId) .map((workspace) => workspace.workspaceId); if (mutation.kind === "remove") { + // Archived workspaces can still represent an empty project in this subscription. + for (const [workspaceId, update] of subscription.lastEmittedByWorkspaceId) { + const projectId = + update.kind === "upsert" ? update.workspace.projectId : update.emptyProject?.projectId; + if (projectId === mutation.projectId && !projectWorkspaceIds.includes(workspaceId)) { + projectWorkspaceIds.push(workspaceId); + } + } const visibleWorkspaceIds = projectWorkspaceIds.filter((workspaceId) => { const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); return ( @@ -3805,7 +3816,7 @@ export class Session { // Re-emit descriptors for every workspace under this project so the new // resolved name lands in the UI immediately. - const workspaces = await this.workspaceRegistry.list(); + const workspaces = await this.workspaceRegistry.listActive(); const affectedWorkspaceIds = workspaces .filter((workspace) => workspace.projectId === existing.projectId) .map((workspace) => workspace.workspaceId); @@ -3857,7 +3868,7 @@ export class Session { }); await this.emitProjectUpdate({ kind: "upsert", project: updated }); - const affectedWorkspaceIds = (await this.workspaceRegistry.list()) + const affectedWorkspaceIds = (await this.workspaceRegistry.listActive()) .filter((workspace) => workspace.projectId === projectId) .map((workspace) => workspace.workspaceId); if (affectedWorkspaceIds.length > 0) { @@ -3903,7 +3914,7 @@ export class Session { try { const project = await this.projectRegistry.get(projectId); const resolvedProjectId = project?.projectId ?? projectId; - const projectWorkspaces = (await this.workspaceRegistry.list()).filter( + const projectWorkspaces = (await this.workspaceRegistry.listActive()).filter( (workspace) => workspace.projectId === resolvedProjectId, ); const activeWorkspaceIds = projectWorkspaces @@ -5471,7 +5482,8 @@ export class Session { return { ok: true, agentId: trimmed }; } - const stored = await this.agentStorage.list(); + // Explicit identifier resolution admits archived agents by ID prefix. + const stored = await this.agentStorage.listAll(); const storedRecords = stored.filter((record) => !record.internal); const knownIds = new Set(); for (const record of storedRecords) { @@ -5583,7 +5595,7 @@ export class Session { Map > { const [persistedWorkspaces, persistedProjects] = await Promise.all([ - this.workspaceRegistry.list(), + this.workspaceRegistry.listActive(), this.projectRegistry.list(), ]); const activeProjects = new Map( @@ -5982,7 +5994,8 @@ export class Session { // than a workspaceId. This is a directory lookup for an archive target, not a // status/ownership attribution. private async findWorkspaceIdForCwd(cwd: string): Promise { - const workspaces = await this.workspaceRegistry.list(); + // Explicit archive administration must prefer an archived exact match to an active ancestor. + const workspaces = await this.workspaceRegistry.listAll(); return resolveWorkspaceIdForPath(cwd, workspaces); } @@ -6173,7 +6186,7 @@ export class Session { } private async listActiveWorkspaceRefs(): Promise { - const workspaces = await this.workspaceRegistry.list(); + const workspaces = await this.workspaceRegistry.listActive(); return workspaces .filter((workspace) => !workspace.archivedAt) .map((workspace) => ({ @@ -6199,7 +6212,7 @@ export class Session { } if (!existingWorkspace.archivedAt) { - const activeSiblings = (await this.workspaceRegistry.list()).filter( + const activeSiblings = (await this.workspaceRegistry.listActive()).filter( (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, ); this.sessionLogger.info( @@ -6404,7 +6417,7 @@ export class Session { // path. See `workspaceIdsOnCheckout`. private async emitWorkspaceUpdateForCwd(cwd: string, options?: {}): Promise { if (this.workspaceUpdatesSubscriptions.size === 0) return; - const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd); + const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.listActive(), cwd); if (workspaceIds.length === 0) { return; } @@ -7158,7 +7171,7 @@ export class Session { projectsBefore.set(project.projectId, project); } const workspacesBefore = new Map(); - for (const workspaceRecord of await this.workspaceRegistry.list()) { + for (const workspaceRecord of await this.workspaceRegistry.listActive()) { workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord); } const workspace = await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(cwd); @@ -7787,7 +7800,10 @@ export class Session { const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]; let agents: AgentSnapshotPayload[]; try { - agents = await this.listAgentPayloads({ includeBackground: true }); + agents = await this.listAgentPayloads({ + workspaceIds: requestedWorkspaceIds, + includeBackground: true, + }); } catch (error) { const message = getErrorMessage(error); const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({ @@ -7927,9 +7943,13 @@ export class Session { throw new Error(`Workspace not found: ${workspaceId}`); } - const agents = (await this.listAgentPayloads({ includeBackground: true })).filter((agent) => - this.isProviderVisibleToClient(agent.provider), - ); + const agents = ( + await this.listAgentPayloads({ + workspaceIds: [workspaceId], + includeBackground: true, + includeAncestors: true, + }) + ).filter((agent) => this.isProviderVisibleToClient(agent.provider)); const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); const candidates = agents .filter((agent) => !agent.archivedAt && !agent.background) diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index f13b90d2c8..3912a2dc8b 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -208,7 +208,10 @@ function createSessionForWorkspaceGitWatchTests(options?: { getAgent: () => null, }), agentStorage: createStub({ - list: async () => [], + listAll: async () => [], + listActive: async () => [], + listByWorkspaces: async () => [], + lastUserMessageAtByWorkspace: async () => new Map(), get: async () => null, }), projectRegistry: createStub({ @@ -233,7 +236,9 @@ function createSessionForWorkspaceGitWatchTests(options?: { subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => + Array.from(workspaces.values()).filter((record) => !record.archivedAt), get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, upsert: async (record: ReturnType) => { workspaces.set(record.workspaceId, record); diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 2a5402a0c3..d0a53de9b2 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -115,7 +115,10 @@ function createHarness(input: { notifyAgentState: () => {}, }), agentStorage: createStub({ - list: async () => [], + listAll: async () => [], + listActive: async () => [], + listByWorkspaces: async () => [], + lastUserMessageAtByWorkspace: async () => new Map(), get: async () => null, }), projectRegistry: createStub({ @@ -155,7 +158,9 @@ function createHarness(input: { subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => + Array.from(workspaces.values()).filter((record) => !record.archivedAt), get: async (id: string) => workspaces.get(id) ?? null, upsert: async (record: PersistedWorkspaceRecord) => { workspaces.set(record.workspaceId, record); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9d83414876..6b1db34449 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,5 @@ +import { AgentDirectory } from "./agent/directory.js"; +import { buildStoredAgentPayload } from "./agent/agent-projections.js"; import { createWorkspaceRuntimeStore, type WorkspaceRuntimeStore, @@ -125,7 +127,7 @@ interface SessionTestAccess { remove(projectId: string): Promise; }; agentStorage: { - list(...args: unknown[]): Promise; + listAll(...args: unknown[]): Promise; get(agentId: string): Promise; upsert(record: unknown): Promise; }; @@ -149,7 +151,8 @@ interface SessionTestAccess { setTitle(agentId: string, title: string): Promise; }; workspaceRegistry: { - list(...args: unknown[]): Promise; + listActive(...args: unknown[]): Promise; + listAll(...args: unknown[]): Promise; archive(workspaceId: string, archivedAt: string): Promise; get(workspaceId: string): Promise; update( @@ -610,7 +613,10 @@ function createSessionForWorkspaceTests( const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-running", projectId: "proj-repo-running", @@ -621,18 +627,9 @@ function createSessionForWorkspaceTests( updatedAt: "2026-03-01T12:00:00.000Z", }), ], - get: async (workspaceId: string) => - workspaceId === "ws-repo-running" - ? createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-running", - projectId: "proj-repo-running", - cwd: REPO_CWD, - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }) - : null, + get: async function (workspaceId: string) { + return (await this.listAll()).find((record) => record.workspaceId === workspaceId) ?? null; + }, update: async (workspaceId, updater) => { if (workspaceId !== "ws-repo-running") { return null; @@ -674,7 +671,7 @@ function createSessionForWorkspaceTests( worktreesRoot: options.worktreesRoot, agentManager, agentStorage: asAgentStorage({ - list: async () => [ + listAll: async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-running", projectId: "proj-repo-running", @@ -1554,7 +1551,7 @@ test("unsupported persisted agents are excluded from active lists but preserved archivedAt: "2026-04-13T10:16:06.514Z", }; - session.agentStorage.list = async () => [storedRecord]; + session.agentStorage.listAll = async () => [storedRecord]; session.agentStorage.get = async (agentId: string) => agentId === storedRecord.id ? storedRecord : null; @@ -1619,7 +1616,9 @@ test("agent_update placement does not refresh git snapshots", async () => { }); session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; await activateAgentUpdatesSubscription(session, "sub-agents", {}); @@ -1751,7 +1750,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" notifyAgentState: () => {}, }), agentStorage: asAgentStorage({ - list: async () => [archivedRecord], + listAll: async () => [archivedRecord], get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), upsert: async (record: typeof archivedRecord) => { Object.assign(archivedRecord, record); @@ -1789,7 +1788,10 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" return { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ws], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ws], get: async (id: string) => (id === "ws-1" ? ws : null), upsert: async () => {}, archive: async () => {}, @@ -1873,7 +1875,9 @@ test("workspace clear attention clears stored-only agents and responds", async ( }); const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; session.projectRegistry.list = async () => [project]; @@ -1988,7 +1992,9 @@ test("workspace mark unread selects the newest finished workspace root", async ( }, }); - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; session.projectRegistry.list = async () => [project]; @@ -2024,6 +2030,99 @@ test("workspace mark unread selects the newest finished workspace root", async ( }); }); +test("workspace mark unread selects a finished cross-workspace child with scoped parent context", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: REPO_CWD, + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const storedRecords = new Map([ + [ + "root-agent", + makeStoredAgent({ + id: "root-agent", + cwd: REPO_CWD, + workspaceId: "parent-workspace", + updatedAt: "2026-03-30T16:00:00.000Z", + }), + ], + [ + "newer-child", + makeStoredAgent({ + id: "newer-child", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T17:00:00.000Z", + labels: { "paseo.parent-agent-id": "root-agent" }, + }), + ], + ]); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + const home = mkdtempSync(path.join(tmpdir(), "mark-unread-parent-")); + const storage = new AgentStorage(path.join(home, "agents"), pino({ level: "silent" })); + for (const record of storedRecords.values()) await storage.upsert(record); + const directory = new AgentDirectory({ + storage, + manager: { listAgents: () => [] }, + workspaces: { get: async () => null }, + projectLive: async () => { + throw new Error("No live agents"); + }, + projectStored: (record) => buildStoredAgentPayload(record, ["codex"]), + isProviderVisible: () => true, + isStoredProviderAvailable: () => true, + }); + session.listAgentPayloads = (filter) => + directory.list(filter as Parameters[0]); + try { + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["newer-child"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: "newer-child", + success: true, + error: null, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test("workspace mark unread rejects workspaces without a finished root agent", async () => { const emitted: SessionOutboundMessage[] = []; const workspace = createPersistedWorkspaceRecord({ @@ -2101,7 +2200,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a ); const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); - session.workspaceRegistry.list = async () => workspaces; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => workspaces; session.workspaceRegistry.get = async (id: string) => workspaces.find((workspace) => workspace.workspaceId === id) ?? null; session.projectRegistry.list = async () => projects; @@ -2228,7 +2327,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy notifyAgentState: () => {}, }), agentStorage: asAgentStorage({ - list: async () => [], + listAll: async () => [], get: async (agentId: string) => { if (agentId !== "agent-1") { return null; @@ -2270,7 +2369,10 @@ test("close_items_request archives agents and kills terminals in one batch", asy return { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ws], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ws], get: async (id: string) => (id === "ws-close" ? ws : null), upsert: async () => {}, archive: async () => {}, @@ -2415,7 +2517,7 @@ test("close_items_request archives stored agents that are not currently loaded", notifyAgentState: () => {}, }), agentStorage: asAgentStorage({ - list: async () => [], + listAll: async () => [], get: async (agentId: string) => { if (agentId === "agent-live") { return liveRecord; @@ -2459,7 +2561,10 @@ test("close_items_request archives stored agents that are not currently loaded", return { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ws], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ws], get: async (id: string) => (id === "ws-stored" ? ws : null), upsert: async () => {}, archive: async () => {}, @@ -2568,7 +2673,7 @@ test("close_items_request continues after an archive failure", async () => { notifyAgentState: () => {}, }), agentStorage: asAgentStorage({ - list: async () => [], + listAll: async () => [], get: async (agentId: string) => { if (agentId !== "agent-good") { return null; @@ -2610,7 +2715,10 @@ test("close_items_request continues after an archive failure", async () => { return { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ws], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ws], get: async (id: string) => (id === "ws-err" ? ws : null), upsert: async () => {}, archive: async () => {}, @@ -2677,7 +2785,7 @@ test("close_items_request continues after an archive failure", async () => { test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { const session = createSessionForWorkspaceTests(); - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-non-git", projectId: "proj-non-git", @@ -2845,7 +2953,7 @@ test("active-scoped fetch_agents includes only unarchived agents in active works session.projectRegistry.list = async () => [activeProject, archivedProject]; session.projectRegistry.get = async (projectId: string) => [activeProject, archivedProject].find((project) => project.projectId === projectId) ?? null; - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ activeWorkspace, archivedWorkspace, workspaceInArchivedProject, @@ -2943,7 +3051,11 @@ test("active-scoped fetch_agents pages within active scope instead of global his session.projectRegistry.list = async () => [project]; session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [activeOne, activeTwo, archivedWorkspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + activeOne, + activeTwo, + archivedWorkspace, + ]; session.listAgentPayloads = async () => [ makeAgent({ id: "active-one", @@ -3021,7 +3133,10 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => }); session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + activeWorkspace, + archivedWorkspace, + ]; session.workspaceRegistry.get = async (workspaceId: string) => [activeWorkspace, archivedWorkspace].find( (workspace) => workspace.workspaceId === workspaceId, @@ -3081,7 +3196,9 @@ test("fetch_agent_history_request pages archived historical rows separately", as if (isSessionOutboundMessage(message)) emitted.push(message); }; session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async () => workspace; session.listAgentPayloads = async () => [ { @@ -3149,7 +3266,9 @@ test("fetch_agent_history_request filters across history and paginates chronolog if (isSessionOutboundMessage(message)) emitted.push(message); }; session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async () => workspace; session.listAgentPayloads = async () => [ // Search keeps chronological order, skipping unrelated rows between pages. @@ -3235,7 +3354,9 @@ test("fetch_agent_history_request rejects a malformed search cursor", async () = if (isSessionOutboundMessage(message)) emitted.push(message); }; session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async () => workspace; session.listAgentPayloads = async () => [ { @@ -3455,7 +3576,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions providerErrors: [], }; }; - session.agentStorage.list = async () => [ + session.agentStorage.listAll = async () => [ { id: "stored-agent", provider: "codex", @@ -3522,7 +3643,7 @@ test("fetch_recent_provider_sessions_request forwards providerFilter to agent ma session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); session.agentManager.listAgents = () => []; - session.agentStorage.list = async () => []; + session.agentStorage.listAll = async () => []; session.agentManager.listImportableSessions = async (options?: unknown) => { capturedOptions = options as { providerFilter?: Set; limit?: number }; return { @@ -3567,7 +3688,7 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun }, }, ]; - session.agentStorage.list = async () => []; + session.agentStorage.listAll = async () => []; session.agentManager.listImportableSessions = async () => ({ sessions: [ { @@ -3661,7 +3782,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = test("git branch workspace uses branch as canonical name", async () => { const session = createSessionForWorkspaceTests(); - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-branch", projectId: "proj-repo-branch", @@ -3704,7 +3825,7 @@ test("git branch workspace uses branch as canonical name", async () => { test("branch/detached policies and dominant status bucket are deterministic", async () => { const session = createSessionForWorkspaceTests(); - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-status", projectId: "proj-repo-status", @@ -3751,7 +3872,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as test("subdirectory agents contribute to their owning workspace descriptor", async () => { const session = createSessionForWorkspaceTests(); - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-subdir", projectId: "proj-repo-subdir", @@ -3817,7 +3938,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto getAgent: () => null, }), agentStorage: asAgentStorage({ - list: async () => [], + listAll: async () => [], get: async () => null, }), projectRegistry: { @@ -3832,7 +3953,10 @@ test("workspace update stream keeps persisted workspace visible after agents sto workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [ + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-running", projectId: "proj-repo-running", @@ -3979,7 +4103,9 @@ test("archiving the last workspace emits a remove carrying the now-empty project }); session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [archivedWorkspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + archivedWorkspace, + ]; session.workspaceRegistry.get = async (workspaceId: string) => workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null; @@ -4047,7 +4173,8 @@ test("project.remove.request archives active workspaces and removes the project }; session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { const existing = workspaces.get(workspaceId); if (!existing) return; @@ -4143,7 +4270,8 @@ test("project.remove.request removes an already-empty project", async () => { }; session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { const existing = workspaces.get(workspaceId); if (!existing) return; @@ -4259,7 +4387,8 @@ test("create paseo worktree response preserves an explicit non-Git project", asy session.paseoHome = paseoHome; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -4339,7 +4468,7 @@ test("workspace updates stay scoped to the matching cwd", async () => { const mainCwd = path.join(missingRoot, "main"); const featureCwd = path.join(missingRoot, "feature"); const session = createSessionForWorkspaceTests(); - session.workspaceRegistry.list = async () => [ + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-main", projectId: "proj-repo-main", @@ -4440,7 +4569,8 @@ test("open_project_request registers a workspace before any agent exists", async workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.buildProjectPlacement = async (cwd: string) => ({ projectKey: cwd, projectName: "repo", @@ -4495,7 +4625,8 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.buildProjectPlacement = async (cwd: string) => ({ projectKey: cwd, projectName: "imported", @@ -4520,7 +4651,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () session.agentManager.importProviderSession = async () => managed; session.agentManager.getTimeline = () => []; session.agentManager.setTitle = async () => undefined; - session.agentStorage.list = async () => []; + session.agentStorage.listAll = async () => []; session.agentStorage.get = async () => null; session.agentUpdates.forwardLiveAgent = async () => undefined; @@ -4609,7 +4740,7 @@ test("import_agent_request imports into the workspace that opened the import she }); }; session.agentManager.getTimeline = () => []; - session.agentStorage.list = async () => []; + session.agentStorage.listAll = async () => []; session.agentStorage.get = async () => null; session.agentUpdates.forwardLiveAgent = async () => undefined; @@ -4640,7 +4771,7 @@ test("import_agent_request maps an import failure to agent_create_failed", async createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - session.agentStorage.list = async () => []; + session.agentStorage.listAll = async () => []; session.agentManager.importProviderSession = async () => { throw new Error("provider session is unavailable"); }; @@ -4685,7 +4816,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({ cwd: requestedCwd, isGit: true, @@ -4761,7 +4893,8 @@ test("open_project_request emits a workspace_update with githubRuntime once the workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({ cwd: requestedCwd, isGit: true, @@ -4872,7 +5005,8 @@ test("open_project_request does not match a new child directory to an existing p workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); await session.handleMessage({ type: "open_project_request", @@ -4941,7 +5075,8 @@ test("open_project_request does not unarchive an archived parent workspace for a workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); await session.handleMessage({ type: "open_project_request", @@ -5016,7 +5151,8 @@ test("open_project_request reclassifies an archived directory workspace when git workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceGitService.getCheckout = async () => ({ cwd, isGit: true, @@ -5132,7 +5268,8 @@ test("open_project_request reclassifies an active directory workspace when git m workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({ cwd: requestedCwd, isGit: true, @@ -5222,7 +5359,8 @@ test("open_project_request gives a plain git worktree its own exact-root project workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({ cwd: requestedCwd, isGit: true, @@ -5311,7 +5449,8 @@ test("open_project_request keeps archived records and allocates a fresh workspac workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); await session.handleMessage({ type: "open_project_request", @@ -5366,7 +5505,8 @@ test("open_project_request does not repurpose an orphaned archived workspace", a workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); await session.handleMessage({ type: "open_project_request", @@ -5426,7 +5566,9 @@ test("workspace recovery stays accepted when git observer warming fails", async }; session.workspaceRegistry.get = async (workspaceId: string) => workspaceId === workspace.workspaceId ? workspace : null; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => { workspace = record; }; @@ -5517,7 +5659,8 @@ test("refresh_agent_request leaves workspace archival independent when its direc workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.upsert = async () => {}; @@ -5615,7 +5758,8 @@ test("refresh_agent_request leaves workspace archival independent when its direc workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.upsert = async () => {}; @@ -5706,7 +5850,8 @@ test("refresh_agent_request does not recreate or unarchive a deleted worktree", workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.upsert = async () => {}; @@ -5805,7 +5950,8 @@ test("refresh_agent_request does not inspect an archived worktree branch", async workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.upsert = async () => {}; @@ -5935,7 +6081,8 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () = workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.upsert = async () => {}; @@ -5993,7 +6140,8 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root workspaces.set(record.workspaceId, record); }; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.buildProjectPlacement = async (cwd: string) => ({ projectKey: repoRoot, projectName: "repo", @@ -6069,7 +6217,9 @@ test("archive_workspace_request hides non-destructive workspace records", async session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => { workspace.archivedAt = archivedAt; }; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.projectRegistry.archive = async () => {}; await session.handleMessage({ @@ -6169,7 +6319,9 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t if (isSessionOutboundMessage(message)) emitted.push(message); }; session.workspaceRegistry.get = async () => workspace; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.archive = async (_id: string, archivedAt: string) => { workspace.archivedAt = archivedAt; }; @@ -6255,7 +6407,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem }; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -6361,7 +6514,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w }; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -6457,7 +6611,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi }; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -6517,7 +6672,9 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; const baselineDescriptor = { id: workspace.workspaceId, @@ -6579,7 +6736,9 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRuntimeStore.get = () => createWorkspaceRuntimeSnapshot(REPO_CWD); const descriptors = Array.from( @@ -6613,7 +6772,9 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi updatedAt: "2026-03-01T12:00:00.000Z", }); session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; return { session, workspace }; }; @@ -6840,7 +7001,10 @@ test("same-cwd workspace descriptors compute agent status per workspaceId", asyn updatedAt: "2026-03-01T12:00:01.000Z", }); session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspaceA, workspaceB]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspaceA, + workspaceB, + ]; // A running agent owned by A leaves the sibling B done — status is per id. session.listAgentPayloads = async () => [ @@ -6897,7 +7061,9 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents const archivedAt = "2026-05-12T09:45:00.000Z"; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.listAgentPayloads = async () => [ makeAgent({ id: "agent-done", @@ -6955,7 +7121,9 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; const readArchivingAt = async () => ( @@ -7003,7 +7171,9 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; emitted.length = 0; session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); @@ -7059,7 +7229,7 @@ test("external workspace updates emit one deduplicated batch", async () => { snapshotReads.projects += 1; return [project]; }; - session.workspaceRegistry.list = async () => { + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => { snapshotReads.workspaces += 1; return [main, feature]; }; @@ -7110,7 +7280,8 @@ test("external workspace updates emit one deduplicated batch", async () => { }, }, ]); - expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 }); + // Scoped projection reads each of the two requested workspace IDs once. + expect(snapshotReads).toEqual({ projects: 1, workspaces: 2 }); }); test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { @@ -7171,7 +7342,9 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git }; session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.buildProjectPlacement = async (cwd: string) => ({ projectKey: cwd, projectName: "repo", @@ -7270,7 +7443,9 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; await session.handleMessage({ type: "fetch_workspaces_request", @@ -7338,7 +7513,9 @@ test("workspace_update includes updated runtime fields", async () => { }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.buildProjectPlacement = async (cwd: string) => ({ projectKey: cwd, projectName: "repo", @@ -7451,7 +7628,10 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho }; session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [gitProject, directoryProject]; - session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + gitWorkspace, + directoryWorkspace, + ]; const describeWorkspaceRecordSubscribed = vi.fn( async (workspace: typeof gitWorkspace, project: unknown) => { if (workspace.workspaceId === gitWorkspace.workspaceId) { @@ -7512,7 +7692,10 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => listedWorkspaces, + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => listedWorkspaces, get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null), update: async () => null, upsert: async () => {}, @@ -7557,14 +7740,16 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch }); listedWorkspaces = [workspace]; + const getWorkspace = workspaceRegistry.get.bind(workspaceRegistry); let resumeDescribe!: () => void; const describeStarted = new Promise((resolveStarted) => { - workspaceRegistry.list = async () => { + workspaceRegistry.get = async (workspaceId) => { + workspaceRegistry.get = getWorkspace; resolveStarted(); await new Promise((resolveResume) => { resumeDescribe = resolveResume; }); - return listedWorkspaces; + return listedWorkspaces.find((record) => record.workspaceId === workspaceId) ?? null; }; }); @@ -7634,7 +7819,10 @@ test("workspace mutations outside a filtered subscription neither watch nor emit const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [workspace], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [workspace], get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null), update: async () => null, upsert: async () => {}, @@ -7750,7 +7938,10 @@ test("project removal mutation broadcasts the final delta to another subscribed workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [workspace], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [workspace], get: async (workspaceId) => (workspaceId === workspace.workspaceId ? workspace : null), update: async () => null, upsert: async () => {}, @@ -7824,7 +8015,10 @@ test("workspace mutation handling drops queued observer sync after session clean const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => listedWorkspaces, + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => listedWorkspaces, get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, update: async () => null, upsert: async () => {}, @@ -8089,7 +8283,7 @@ test("project.icon.set.request publishes a custom icon that project.icon.get ser projects.set(id, updated); return updated; }; - session.workspaceRegistry.list = async () => []; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => []; await session.handleMessage({ type: "project.icon.set.request", @@ -8179,7 +8373,9 @@ test("project.rename.request stores customName and emits an updated workspace de const parsed = record as typeof project; projects.set(parsed.projectId, parsed); }; - session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ + workspace, + ]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; @@ -8235,7 +8431,7 @@ test("project.rename.request updates a project with no workspaces", async () => }); session.projectRegistry.get = async () => project; session.projectRegistry.upsert = async () => project; - session.workspaceRegistry.list = async () => []; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => []; await session.handleMessage({ type: "project.rename.request", @@ -8277,7 +8473,7 @@ test("project.rename.request with whitespace-only customName clears the override const parsed = record as typeof project; projects.set(parsed.projectId, parsed); }; - session.workspaceRegistry.list = async () => []; + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => []; await session.handleMessage({ type: "project.rename.request", @@ -8348,7 +8544,8 @@ test("workspace.title.set.request stores the title and emits an updated descript const workspaces = new Map([[workspace.workspaceId, workspace]]); session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; session.workspaceRegistry.update = async (id, updater) => { const existing = workspaces.get(id); @@ -8418,7 +8615,8 @@ test("workspace.pin.set.request stores the pin timestamp and emits an updated de const workspaces = new Map([[workspace.workspaceId, workspace]]); session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.update = async (id, updater) => { const existing = workspaces.get(id); if (!existing) return null; @@ -8475,7 +8673,8 @@ test("workspace.title.set.request with whitespace-only title clears the title", }); const workspaces = new Map([[workspace.workspaceId, workspace]]); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; session.workspaceRegistry.update = async (id, updater) => { const existing = workspaces.get(id); @@ -8549,7 +8748,10 @@ async function createSessionWithTerminalManager(options: { const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => options.workspaces, + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => options.workspaces, get: async (workspaceId: string) => options.workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null, upsert: async () => {}, @@ -8606,7 +8808,7 @@ test("overlapping workspace rebuilds publish the newest provider subagent status let listener: ((event: AgentManagerEvent) => void) | null = null; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message), - agentStorage: { list: async () => [] }, + agentStorage: { listAll: async () => [] }, agentManager: { subscribe: (nextListener: (event: AgentManagerEvent) => void) => { listener = nextListener; @@ -9153,7 +9355,10 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => Array.from(workspaces.values()), get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, upsert: async (record) => { workspaces.set(record.workspaceId, record); @@ -9477,7 +9682,8 @@ test("checkout.rename_branch.request renames the branch without a denormalized b const workspaces = new Map([[workspace.workspaceId, workspace]]); const upsertedRecords: Array = []; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; session.workspaceRegistry.upsert = async (record: unknown) => { const parsed = record as typeof workspace; @@ -9517,7 +9723,10 @@ test("workspace.create.response persists the first prompt as the initial title", workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => Array.from(workspaces.values()), get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, upsert: async (workspace) => { workspaces.set(workspace.workspaceId, workspace); @@ -9558,7 +9767,10 @@ test("workspace create emits through a matching workspace subscription", async ( workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => Array.from(workspaces.values()), get: async (workspaceId) => workspaces.get(workspaceId) ?? null, upsert: async (workspace, context) => { workspaces.set(workspace.workspaceId, workspace); @@ -9610,7 +9822,10 @@ test("workspace create stays out of a non-matching workspace subscription", asyn workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => Array.from(workspaces.values()), get: async (workspaceId) => workspaces.get(workspaceId) ?? null, upsert: async (workspace) => { workspaces.set(workspace.workspaceId, workspace); @@ -9692,7 +9907,8 @@ test("workspace.create.request with background persists a hidden workspace that }; session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => + Array.from(workspaces.values()); await session.handleMessage({ type: "workspace.create.request", @@ -9887,7 +10103,10 @@ test("workspace PR status survives session cleanup and reconnect", async () => { const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [workspace], + listActive: async function () { + return (await this.listAll()).filter((record) => !record.archivedAt); + }, + listAll: async () => [workspace], get: async (id) => (id === workspace.workspaceId ? workspace : null), update: async (id, updater) => (id === workspace.workspaceId ? updater(workspace) : null), upsert: async () => {}, @@ -10058,3 +10277,77 @@ test("workspace PR status survives session cleanup and reconnect", async () => { await gitService.dispose(); } }); + +test("path archive of an archived exact child leaves its active parent untouched", async () => { + const now = "2026-03-30T15:00:00.000Z"; + const root = mkdtempSync(path.join(tmpdir(), "archive-exact-child-")); + const childPath = path.join(root, "sub"); + mkdirSync(childPath); + const parent = createPersistedWorkspaceRecord({ + workspaceId: "parent", + projectId: "project", + cwd: root, + kind: "directory", + displayName: "parent", + createdAt: now, + updatedAt: now, + }); + const child = createPersistedWorkspaceRecord({ + workspaceId: "child", + projectId: "project", + cwd: childPath, + kind: "directory", + displayName: "child", + createdAt: now, + updatedAt: now, + archivedAt: now, + }); + const records = [parent, child]; + const archivedAgents: string[] = []; + const archivedWorkspaces: string[] = []; + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + listAgents: () => [ + makeManagedAgent({ + id: "parent-agent", + cwd: root, + workspaceId: parent.workspaceId, + lifecycle: "idle", + updatedAt: now, + }), + ], + archiveAgent: async (id: string) => { + archivedAgents.push(id); + return { archivedAt: now }; + }, + }, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + listAll: async () => records, + listActive: async () => records.filter((record) => !record.archivedAt), + get: async (id) => records.find((record) => record.workspaceId === id) ?? null, + upsert: async () => {}, + update: async () => null, + remove: async () => {}, + archive: async (id) => { + archivedWorkspaces.push(id); + }, + }, + }); + try { + await session.handleMessage({ + type: "paseo_worktree_archive_request", + requestId: "archive-exact", + worktreePath: childPath, + }); + expect(archivedAgents).toEqual([]); + expect(archivedWorkspaces).toEqual([]); + expect(parent.archivedAt).toBeNull(); + expect(existsSync(root)).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index c93e535709..d9f7ccea96 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -132,7 +132,7 @@ test("fresh git repo creates a workspace at the canonical worktree root", async const workspace = await provisioning.findOrCreateWorkspaceForDirectory(repo); expect(workspace.cwd).toBe(repo); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); expect(await projectRegistry.list()).toHaveLength(1); }); @@ -192,7 +192,7 @@ test("re-opening an active workspace by exact path returns the same record witho const second = await provisioning.findOrCreateWorkspaceForDirectory(repo); expect(second.workspaceId).toBe(first.workspaceId); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); }); test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => { @@ -202,12 +202,12 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd); expect(active.workspaceId).toBe(created.workspaceId); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd); expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null }); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); }); test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => { @@ -361,14 +361,15 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); - const archived = (await workspaceRegistry.list()).filter( + const archived = (await workspaceRegistry.listAll()).filter( (workspace) => workspace.workspaceId === created.workspaceId, ); let reads = 0; const snapshotRegistry: WorkspaceRegistry = { initialize: () => workspaceRegistry.initialize(), existsOnDisk: () => workspaceRegistry.existsOnDisk(), - list: async () => (reads++ === 0 ? archived : []), + listAll: async () => (reads++ === 0 ? archived : []), + listActive: () => workspaceRegistry.listActive(), get: (workspaceId) => workspaceRegistry.get(workspaceId), upsert: (workspace) => workspaceRegistry.upsert(workspace), archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt), @@ -384,7 +385,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo); expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null }); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); }); test("reopening an archived workspace refreshes placement without renaming it", async () => { @@ -540,7 +541,7 @@ test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id w }); expect(id).toBe("ws-from-worktree"); - expect(await workspaceRegistry.list()).toHaveLength(0); + expect(await workspaceRegistry.listAll()).toHaveLength(0); }); test("resolveOrCreateWorkspaceIdForCreateAgent honors an explicitly requested workspace id", async () => { @@ -552,7 +553,7 @@ test("resolveOrCreateWorkspaceIdForCreateAgent honors an explicitly requested wo }); expect(id).toBe("ws-requested"); - expect(await workspaceRegistry.list()).toHaveLength(0); + expect(await workspaceRegistry.listAll()).toHaveLength(0); }); test("resolveOrCreateWorkspaceIdForCreateAgent creates a titled workspace when nothing is provided", async () => { @@ -577,7 +578,7 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a const second = await provisioning.createWorkspaceForDirectory(repo); expect(second.workspaceId).not.toBe(first.workspaceId); - expect(await workspaceRegistry.list()).toHaveLength(2); + expect(await workspaceRegistry.listAll()).toHaveLength(2); }); test("directory creation persists the live branch and a trimmed title", async () => { @@ -677,7 +678,7 @@ test("runInImportWorkspace uses an active requested workspace without creating a ); expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null }); - expect(await workspaceRegistry.list()).toEqual([workspace]); + expect(await workspaceRegistry.listAll()).toEqual([workspace]); }); test("runInImportWorkspace reuses an active workspace for an untargeted import", async () => { @@ -691,7 +692,7 @@ test("runInImportWorkspace reuses an active workspace for an untargeted import", ); expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null }); - expect(await workspaceRegistry.list()).toEqual([workspace]); + expect(await workspaceRegistry.listAll()).toEqual([workspace]); }); test("runInImportWorkspace unarchives a workspace for an untargeted import", async () => { @@ -710,7 +711,7 @@ test("runInImportWorkspace unarchives a workspace for an untargeted import", asy workspaceId: workspace.workspaceId, archivedAt: null, }); - expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.listAll()).toHaveLength(1); }); test("runInImportWorkspace leaves a reused workspace intact when import fails", async () => { @@ -725,7 +726,7 @@ test("runInImportWorkspace leaves a reused workspace intact when import fails", }), ).rejects.toThrow("provider session is unavailable"); - expect(await workspaceRegistry.list()).toEqual([workspace]); + expect(await workspaceRegistry.listAll()).toEqual([workspace]); expect(await projectRegistry.get(workspace.projectId)).toEqual(project); }); @@ -823,7 +824,7 @@ test("runInImportWorkspace creates one fresh workspace for an untargeted import" ); expect(result.value).toBe(result.createdWorkspace?.workspaceId); - expect(await workspaceRegistry.list()).toEqual([result.createdWorkspace]); + expect(await workspaceRegistry.listAll()).toEqual([result.createdWorkspace]); }); test.each(["missing", "archived"] as const)( @@ -844,7 +845,7 @@ test.each(["missing", "archived"] as const)( }), ).rejects.toThrow("provider session is unavailable"); - expect(await workspaceRegistry.list()).toEqual([]); + expect(await workspaceRegistry.listAll()).toEqual([]); expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []); }, ); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 08d7b93bf4..c4554688e0 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -149,7 +149,8 @@ export function createWorkspaceProvisioningService(deps: { const [projectsBeforeImport, workspacesBeforeImport] = await Promise.all([ projectRegistry.list(), - workspaceRegistry.list(), + // Import rollback must distinguish restored records from newly allocated workspaces. + workspaceRegistry.listAll(), ]); const workspace = await findOrCreateWorkspaceForDirectory(input.cwd); const createdWorkspace = workspacesBeforeImport.some( @@ -179,7 +180,7 @@ export function createWorkspaceProvisioningService(deps: { ): Promise { try { await workspaceRegistry.remove(workspace.workspaceId); - const projectHasActiveWorkspace = (await workspaceRegistry.list()).some( + const projectHasActiveWorkspace = (await workspaceRegistry.listActive()).some( (candidate) => candidate.projectId === workspace.projectId && !candidate.archivedAt, ); if (projectHasActiveWorkspace) { @@ -321,7 +322,7 @@ export function createWorkspaceProvisioningService(deps: { return refreshProjectKind(await requireActiveProject(input.projectId)); } - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listActive(); const sourceWorkspace = workspaces.find( (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd), @@ -356,7 +357,8 @@ export function createWorkspaceProvisioningService(deps: { async function findOrCreateWorkspaceForDirectory(cwd: string): Promise { const normalizedCwd = resolve(cwd); // Path-based discovery selects public work; explicit-ID recovery bypasses this selector. - const workspaces = (await workspaceRegistry.list()) + // Opening a directory explicitly recovers an archived workspace at that path. + const workspaces = (await workspaceRegistry.listAll()) .filter( (workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd), ) diff --git a/packages/server/src/server/test-fixtures/reconciliation-registries.ts b/packages/server/src/server/test-fixtures/reconciliation-registries.ts index 2d020ec23f..b6490e8f88 100644 --- a/packages/server/src/server/test-fixtures/reconciliation-registries.ts +++ b/packages/server/src/server/test-fixtures/reconciliation-registries.ts @@ -64,7 +64,8 @@ export function createTestRegistries() { const workspaceRegistry: WorkspaceRegistry = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt), get: async (id: string) => workspaces.get(id) ?? null, update: async (id, updater) => { const existing = workspaces.get(id); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 922f673935..da61f3495c 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -45,6 +45,19 @@ export function asAgentStorage(stub: { [K in keyof SessionOptions["agentStorage"]]?: unknown; }): SessionOptions["agentStorage"] { return createStub({ + listAll: async () => [], + listActive: async function (options) { + return (await this.listAll()).filter( + (record) => options?.includeArchived || !record.archivedAt, + ); + }, + listByWorkspaces: async function (ids, options) { + const selected = new Set(ids); + return (await this.listActive(options)).filter((record) => + selected.has(record.workspaceId ?? ""), + ); + }, + lastUserMessageAtByWorkspace: async () => new Map(), listByProviderSession: async () => [], listByWorkspace: async () => [], ...stub, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b04e722406..61014327fd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -358,7 +358,8 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { return { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [], + listAll: async () => [], + listActive: async () => [], get: async () => null, update: async () => null, upsert: async () => {}, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 00faf1f441..9e563ec40b 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -128,13 +128,22 @@ class InMemoryAgentManager { } class EmptyAgentStorage { - async list() { + async listActive() { return []; } async get() { return null; } + async listByWorkspaces() { + return []; + } + async lastUserMessageAtByWorkspace() { + return new Map(); + } + async listAll() { + return []; + } } class EmptyProjectRegistry { @@ -156,6 +165,12 @@ class EmptyProjectRegistry { } class EmptyWorkspaceRegistry { + listActive() { + return []; + } + listAll() { + return []; + } get() { return null; } @@ -753,7 +768,7 @@ test("setup observation includes a transition that happens during its initial re readStarted(); return pendingRead; }, - list: () => [], + listActive: () => [], } as unknown as SessionOptions["workspaceRegistry"], }); session.updateClientCapabilities( diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 567c80054f..43ba26b8ad 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -973,8 +973,9 @@ describe("archiveByScope", () => { }), }; deps.agentStorage = { - list: async () => [{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[], - } as Pick; + listByWorkspace: async () => + [{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[], + } as Pick; const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId }, diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index a319860a9a..ab2ead15aa 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -1,4 +1,12 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { buildStoredAgentPayload } from "./agent/agent-projections.js"; +import type { ManagedAgent } from "./agent/agent-manager.js"; +import { Session as InspectorSession } from "node:inspector/promises"; +import type { HeapProfiler } from "node:inspector"; +import { + FileBackedWorkspaceRegistry, + createPersistedWorkspaceRecord, +} from "./workspace-registry.js"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AgentStorage } from "./agent/agent-storage.js"; @@ -73,7 +81,10 @@ class WorkspaceStatus { private readonly directory = new WorkspaceDirectory({ logger: createTestLogger(), projectRegistry: { list: async () => [this.project] }, - workspaceRegistry: { list: async () => this.workspaces }, + workspaceRegistry: { + listActive: async () => this.workspaces.filter((w) => !w.archivedAt), + get: async (id) => this.workspaces.find((w) => w.workspaceId === id) ?? null, + }, listAgentPayloads: async () => this.agents, workspaceLastUserMessageTimes: async () => { const times = new Map(); @@ -586,7 +597,10 @@ describe("WorkspaceDirectory empty projects", () => { return new WorkspaceDirectory({ logger: createTestLogger(), projectRegistry: { list: async () => input.projects }, - workspaceRegistry: { list: async () => input.workspaces }, + workspaceRegistry: { + listActive: async () => input.workspaces.filter((w) => !w.archivedAt), + get: async (id) => input.workspaces.find((w) => w.workspaceId === id) ?? null, + }, listAgentPayloads: async () => [], workspaceLastUserMessageTimes: async () => new Map(), listProviderSubagentActivity: async () => [], @@ -745,9 +759,9 @@ test("Git observation targets exclude archived records without hydrating app des logger: createTestLogger(), projectRegistry: { list: async () => [project("active"), project("archived", NOW)] }, workspaceRegistry: { - list: async () => [ + get: async () => null, + listActive: async () => [ workspace("observed", "active"), - workspace("hidden", "active", NOW), workspace("hidden-project", "archived"), ], }, @@ -810,13 +824,22 @@ test("prompt recency reads archived records without projecting agents or exposin lastUserMessageAt: "2026-03-05T00:00:00.000Z", }); await storage.upsert({ ...base, id: "output", lastActivityAt: "2026-03-06T00:00:00.000Z" }); + await storage.upsert({ ...base, id: "live", lastUserMessageAt: "2026-03-09T00:00:00.000Z" }); const unexpectedProjection = (): never => { throw new Error("Prompt sorting projected an agent payload"); }; const directory = new AgentDirectory({ storage, - manager: { listAgents: () => [] }, - workspaces: { get: async () => null, list: async () => [] }, + manager: { + listAgents: () => [ + { + id: "live", + workspaceId: "w", + lastUserMessageAt: new Date("2026-03-02T00:00:00.000Z"), + } as ManagedAgent, + ], + }, + workspaces: { get: async () => null }, projectLive: unexpectedProjection, projectStored: unexpectedProjection, isProviderVisible: () => false, @@ -829,3 +852,148 @@ test("prompt recency reads archived records without projecting agents or exposin await rm(home, { recursive: true, force: true }); } }); + +test("scoped descriptor builds allocate independently of archived history", async () => { + const home = await mkdtemp(join(tmpdir(), "descriptor-history-")); + try { + const logger = createTestLogger(); + const agentsPath = join(home, "agents"); + await mkdir(agentsPath); + const records = Array.from({ length: 10001 }, (_, i) => + createPersistedWorkspaceRecord({ + workspaceId: `w${i}`, + projectId: "p", + cwd: home, + kind: "directory", + displayName: "workspace", + createdAt: NOW, + updatedAt: NOW, + archivedAt: i ? NOW : null, + }), + ); + await writeFile(join(home, "workspaces.json"), JSON.stringify(records)); + for (let offset = 0; offset < 10000; offset += 100) + await Promise.all( + Array.from({ length: 100 }, (_, i) => { + const id = `a${offset + i}`; + return writeFile( + join(agentsPath, `${id}.json`), + JSON.stringify({ + id, + provider: "codex", + cwd: home, + workspaceId: "w1", + createdAt: NOW, + updatedAt: NOW, + labels: {}, + archivedAt: NOW, + lastUserMessageAt: NOW, + }), + ); + }), + ); + const storage = new AgentStorage(agentsPath, logger); + const registry = new FileBackedWorkspaceRegistry(join(home, "workspaces.json"), logger); + await storage.initialize(); + await storage.upsert({ + id: "parent", + provider: "codex", + cwd: home, + workspaceId: "w2", + createdAt: NOW, + updatedAt: NOW, + }); + await storage.upsert({ + id: "child", + provider: "codex", + cwd: home, + workspaceId: "w0", + createdAt: NOW, + updatedAt: NOW, + lastStatus: "running", + labels: { "paseo.parent-agent-id": "parent" }, + }); + await registry.initialize(); + const agents = new AgentDirectory({ + storage, + workspaces: registry, + manager: { listAgents: () => [] }, + projectLive: async () => { + throw new Error("Unexpected live projection"); + }, + projectStored: (record) => buildStoredAgentPayload(record, ["codex"]), + isProviderVisible: () => true, + isStoredProviderAvailable: () => true, + }); + const directory = new WorkspaceDirectory({ + logger, + workspaceRegistry: registry, + projectRegistry: { + list: async () => [ + { + projectId: "p", + rootPath: home, + kind: "non_git", + displayName: "project", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }, + ], + }, + listAgentPayloads: (workspaceIds) => + agents.list({ workspaceIds, includeBackground: true, includeAncestors: true }), + workspaceLastUserMessageTimes: (workspaceIds) => + agents.workspaceLastUserMessageTimes(workspaceIds), + listProviderSubagentActivity: async () => [], + listTerminalActivityContributions: async () => [], + isProviderVisibleToClient: () => true, + buildWorkspaceDescriptor: async ({ workspace }) => ({ + id: workspace.workspaceId, + projectId: "p", + projectDisplayName: "project", + projectRootPath: home, + projectKind: "non_git", + workspaceDirectory: home, + workspaceKind: "directory", + name: "workspace", + status: "done", + activityAt: null, + diffStat: null, + scripts: [], + gitRuntime: null, + githubRuntime: null, + }), + }); + const scoped = await directory.buildDescriptorMap({ + workspaceIds: new Set(["w0"]).values(), + includeGitData: false, + }); + expect(scoped.get("w0")?.status).toBe("running"); + const inspector = new InspectorSession(); + inspector.connect(); + let bytes = 0; + try { + await inspector.post("HeapProfiler.startSampling", { + samplingInterval: 16384, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + for (let i = 0; i < 100; i++) + await directory.buildDescriptorMap({ + workspaceIds: new Set(["w0"]).values(), + includeGitData: false, + }); + const { profile } = await inspector.post("HeapProfiler.stopSampling"); + function sum(node: HeapProfiler.SamplingHeapProfileNode): number { + return node.selfSize + node.children.reduce((total, child) => total + sum(child), 0); + } + bytes = sum(profile.head); + } finally { + inspector.disconnect(); + } + expect(bytes).toBeLessThan(8 * 1024 * 1024); + } finally { + await rm(home, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 24f4598799..64f544e6f8 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -74,10 +74,13 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; workspaceRegistry: { - list(): Promise; + listActive(): Promise; + get(id: string): Promise; }; - listAgentPayloads(): Promise; - workspaceLastUserMessageTimes(): Promise>; + listAgentPayloads(workspaceIds?: Iterable): Promise; + workspaceLastUserMessageTimes( + workspaceIds?: Iterable, + ): Promise>; listProviderSubagentActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> @@ -217,6 +220,7 @@ export class WorkspaceDirectory { includeGitData: boolean; workspaceIds?: Iterable; }): Promise> { + const requestedIds = options.workspaceIds ? new Set(options.workspaceIds) : undefined; const [ agents, lastUserMessageTimes, @@ -225,10 +229,15 @@ export class WorkspaceDirectory { persistedProjects, terminalContributions, ] = await Promise.all([ - this.deps.listAgentPayloads(), - this.deps.workspaceLastUserMessageTimes(), + this.deps.listAgentPayloads(requestedIds), + this.deps.workspaceLastUserMessageTimes(requestedIds), this.deps.listProviderSubagentActivity(), - this.deps.workspaceRegistry.list(), + requestedIds + ? Promise.all(Array.from(requestedIds, (id) => this.deps.workspaceRegistry.get(id))).then( + (records) => + records.filter((record): record is PersistedWorkspaceRecord => record !== null), + ) + : this.deps.workspaceRegistry.listActive(), this.deps.projectRegistry.list(), this.deps.listTerminalActivityContributions(), ]); @@ -240,7 +249,7 @@ export class WorkspaceDirectory { ); const activeRecords = activeWorkspaceRecords(persistedWorkspaces, persistedProjects); const descriptorsByWorkspaceId = new Map(); - const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; + const workspaceIds = requestedIds ? new Set(requestedIds) : null; const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); const includedWorkspaces = activeRecords.filter( (workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId), @@ -570,7 +579,7 @@ export class WorkspaceDirectory { includeBackground?: boolean; }): Promise { const [persistedWorkspaces, persistedProjects] = await Promise.all([ - this.deps.workspaceRegistry.list(), + this.deps.workspaceRegistry.listActive(), this.deps.projectRegistry.list(), ]); // A project whose only workspaces are hidden reads as empty to that caller. @@ -601,7 +610,7 @@ export class WorkspaceDirectory { Pick[] > { const [workspaces, projects] = await Promise.all([ - this.deps.workspaceRegistry.list(), + this.deps.workspaceRegistry.listActive(), this.deps.projectRegistry.list(), ]); return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({ diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index 44bb9de08e..f8cd5f106a 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -80,7 +80,7 @@ class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry { this.nextError = error; } - override async list() { + override async listActive() { if (this.nextRead) { const pending = this.nextRead; this.nextRead = null; @@ -91,7 +91,7 @@ class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry { this.nextError = null; throw error; } - return super.list(); + return super.listActive(); } } diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 5333da8f83..a007dedb59 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -213,7 +213,7 @@ export class WorkspaceReconciliationService { const changes: ReconciliationChange[] = []; const [projects, workspaces] = await Promise.all([ this.projectRegistry.list(), - this.workspaceRegistry.list(), + this.workspaceRegistry.listActive(), ]); const workspacesByProject = new Map(); const workspaceStates = await mapBounded( @@ -245,7 +245,7 @@ export class WorkspaceReconciliationService { const changes: ReconciliationChange[] = []; const allProjects = await this.projectRegistry.list(); - const allWorkspaces = await this.workspaceRegistry.list(); + const allWorkspaces = await this.workspaceRegistry.listActive(); const activeProjects = allProjects.filter((p) => !p.archivedAt); const activeProjectsById = new Map( @@ -547,7 +547,7 @@ export class WorkspaceReconciliationService { if (change.kind === "project_updated") projectIds.add(change.projectId); } if (projectIds.size > 0) { - const workspaces = await this.workspaceRegistry.list(); + const workspaces = await this.workspaceRegistry.listActive(); for (const workspaceId of workspaceIdsForProjects(workspaces, projectIds)) { workspaceIds.add(workspaceId); } diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index a2a1ca5677..e8a6558b3f 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -87,7 +87,7 @@ describe("bootstrapWorkspaceRegistries", () => { }); expect(await projectRegistry.list()).toEqual([]); - expect(await workspaceRegistry.list()).toEqual([]); + expect(await workspaceRegistry.listAll()).toEqual([]); }); test("skips a legacy agent whose cwd is a file", async () => { @@ -128,7 +128,7 @@ describe("bootstrapWorkspaceRegistries", () => { }); expect(await projectRegistry.list()).toEqual([]); - expect(await workspaceRegistry.list()).toEqual([]); + expect(await workspaceRegistry.listAll()).toEqual([]); }); test("propagates a Git failure for an existing legacy directory", async () => { @@ -233,7 +233,7 @@ describe("bootstrapWorkspaceRegistries", () => { logger, }); - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listAll(); expect(workspaces).toHaveLength(1); expect(workspaces[0]?.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/); expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT); @@ -307,8 +307,8 @@ describe("bootstrapWorkspaceRegistries", () => { }); expect(await projectRegistry.list()).toHaveLength(1); - expect(await workspaceRegistry.list()).toHaveLength(1); - expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing"); + expect(await workspaceRegistry.listAll()).toHaveLength(1); + expect((await workspaceRegistry.listAll())[0]?.workspaceId).toBe("ws-existing"); }); test("materializes legacy remote worktrees into one readable project", async () => { @@ -366,7 +366,7 @@ describe("bootstrapWorkspaceRegistries", () => { displayName: "acme/legacy-project", }); - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listAll(); expect( workspaces .map(({ projectId, cwd, kind, displayName }) => ({ projectId, cwd, kind, displayName })) @@ -449,7 +449,7 @@ describe("bootstrapWorkspaceRegistries", () => { }); expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older"); - expect(await workspaceRegistry.list()).toHaveLength(2); + expect(await workspaceRegistry.listAll()).toHaveLength(2); }); test("migrated legacy agents stay owned by the deterministic workspace when a same-cwd workspace is added later", async () => { @@ -568,7 +568,7 @@ describe("bootstrapWorkspaceRegistries", () => { logger, }); - const workspaces = await workspaceRegistry.list(); + const workspaces = await workspaceRegistry.listAll(); expect(workspaces).toHaveLength(1); expect(workspaces[0]?.workspaceId).toBe("ws-existing"); expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 817853dad9..7b9770fff2 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -64,7 +64,7 @@ export async function bootstrapWorkspaceRegistries(options: { // COMPAT(worktree-branch-identity): added in v0.4.0 on 2026-08-15; remove after // 2027-02-15. Older worktrees did not pin branch-off/check-out branch identity. // Seed it from the registry value clients already display, never from live Git. - for (const workspace of await options.workspaceRegistry.list()) { + for (const workspace of await options.workspaceRegistry.listActive()) { if ( workspace.archivedAt || !workspace.isPaseoOwnedWorktree || @@ -89,12 +89,14 @@ export async function bootstrapWorkspaceRegistries(options: { } const existingWorkspaceIdsByCwd = new Map( - (await options.workspaceRegistry.list()).map((workspace) => [ + // Startup backfill resolves workspace ownership across archived records too. + (await options.workspaceRegistry.listAll()).map((workspace) => [ path.resolve(workspace.cwd), workspace.workspaceId, ]), ); - const records = await options.agentStorage.list(); + // Startup backfill repairs agents without workspace membership, including archived history. + const records = await options.agentStorage.listAll(); // A legacy agent can outlive its working directory. Reconciliation treats a // missing directory as absent rather than asking Git about it; bootstrap must // do the same before materializing its first workspace record. diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 15031e7b69..c26a2fe447 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -500,7 +500,7 @@ describe("workspace registries", () => { workspace: archived, }); expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); - expect(await workspaceRegistry.list()).toEqual([]); + expect(await workspaceRegistry.listAll()).toEqual([]); }); test("preserves the first workspace archive and reports whether a queued archive applied", async () => { diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 61fcb4f720..474fc24829 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -156,7 +156,8 @@ export interface ProjectRegistry { export interface WorkspaceRegistry { initialize(): Promise; existsOnDisk(): Promise; - list(): Promise; + listAll(): Promise; + listActive(): Promise; get(workspaceId: string): Promise; update( workspaceId: string, @@ -182,6 +183,7 @@ class FileBackedRegistry { protected readonly logger: Logger; private readonly schema: z.ZodType; private readonly getId: (record: TRecord) => string; + private readonly activeIds = new Set(); private loaded = false; private readonly cache = new Map(); private mutationQueue: Promise = Promise.resolve(); @@ -219,11 +221,16 @@ class FileBackedRegistry { } } - async list(): Promise { + async listAll(): Promise { await this.load(); return Array.from(this.cache.values()); } + async listActive(): Promise { + await this.load(); + return Array.from(this.activeIds, (id) => this.cache.get(id)!); + } + async get(id: string): Promise { await this.load(); return this.cache.get(id) ?? null; @@ -288,6 +295,7 @@ class FileBackedRegistry { this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); } } + this.rebuildActiveIndex(); this.loaded = true; } @@ -335,6 +343,7 @@ class FileBackedRegistry { this.cache.clear(); for (const [id, record] of staged) this.cache.set(id, record); } + this.rebuildActiveIndex(); hooks?.afterCommit?.(); return result; } finally { @@ -342,6 +351,11 @@ class FileBackedRegistry { } } + private rebuildActiveIndex(): void { + this.activeIds.clear(); + for (const [id, record] of this.cache) if (!record.archivedAt) this.activeIds.add(id); + } + protected freezeMutationsUntilRestart(): void { this.mutationsBlockedUntilRestart = true; } @@ -359,6 +373,10 @@ export class FileBackedProjectRegistry extends FileBackedRegistry implements ProjectRegistry { + async list(): Promise { + return this.listAll(); + } + private allocationQueue: Promise = Promise.resolve(); private readonly projectIdFactory: () => string; private readonly mutationListeners = new Set< diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index a5119faa5a..845e8e0a75 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -331,7 +331,8 @@ function createPaseoWorktreeForTest(options: { const workspaceRegistry: WorkspaceRegistry = { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), + listAll: async () => Array.from(workspaces.values()), + listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt), get: async (workspaceId) => workspaces.get(workspaceId) ?? null, update: async (workspaceId, updater) => { const workspace = workspaces.get(workspaceId); @@ -477,9 +478,9 @@ describe("create-agent worktree setup boundary", () => { }); }); -function createAgentStorageStub(): Pick { +function createAgentStorageStub(): Pick { return { - list: async (): Promise => [], + listAll: async (): Promise => [], }; }