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
This commit is contained in:
Mohamed Boudra
2026-11-04 13:25:24 +07:00
committed by GitHub
parent 0952ddbd4b
commit d37262be56
43 changed files with 1210 additions and 315 deletions
@@ -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<StoredAgentRecord[]> {
const records = await super.list();
override async listActive(): Promise<StoredAgentRecord[]> {
const records = await super.listAll();
const parentIsArchived = records.some(
(record) => record.id !== this.childId && Boolean(record.archivedAt),
);
@@ -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<void> {
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 &&
@@ -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);
});
});
+132 -17
View File
@@ -105,9 +105,14 @@ export class AgentStorage {
private deleting: Set<string> = new Set();
private daemonAgentIdsByExecution: Map<string, string> = new Map();
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
private readonly idsByWorkspace = new Map<string, Set<string>>();
private readonly workspaceById = new Map<string, string>();
private readonly activeIds = new Set<string>();
private readonly promptTimeByAgent = new Map<string, string>();
private readonly promptTimes = new Map<string, string>();
private loaded = false;
private baseDir: string;
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
private loadPromise: Promise<void> | null = null;
private logger: Logger;
constructor(baseDir: string, logger: Logger) {
@@ -119,11 +124,119 @@ export class AgentStorage {
await this.load();
}
async list(): Promise<StoredAgentRecord[]> {
async listAll(): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(
options?.includeArchived ? this.cache.keys() : this.activeIds,
(id) => this.cache.get(id)!,
);
}
async listByWorkspaces(
workspaceIds: Iterable<string>,
options?: { includeArchived?: boolean },
): Promise<StoredAgentRecord[]> {
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<string>,
options?: { excludeAgentIds?: Iterable<string> },
): Promise<ReadonlyMap<string, string>> {
await this.load();
const times = new Map<string, string>();
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<string>();
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<StoredAgentRecord | null> {
await this.load();
return this.cache.get(agentId) ?? null;
@@ -143,8 +256,7 @@ export class AgentStorage {
}
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
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<StoredAgentRecord | null> {
@@ -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<StoredAgentRecord[]> {
private async load(): Promise<void> {
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<StoredAgentRecord[]> {
private async doLoad(): Promise<void> {
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<StoredAgentRecord[]> {
const records: StoredAgentRecord[] = [];
private async scanDisk(): Promise<void> {
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<StoredAgentRecord | null> {
@@ -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);
@@ -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");
});
+72 -24
View File
@@ -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<AgentManager, "listAgents">;
storage: Pick<AgentStorage, "list">;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
storage: Pick<
AgentStorage,
"get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace"
>;
workspaces: Pick<WorkspaceRegistry, "get">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
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<Map<string, string>> {
async workspaceLastUserMessageTimes(
workspaceIds?: Iterable<string>,
): Promise<Map<string, string>> {
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<string, string>();
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<void> {
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<string>;
/** Include parent chains by ID for workspace root classification. */
includeAncestors?: boolean;
labels?: Record<string, string>;
includeArchived?: boolean;
includeBackground?: BackgroundVisibility;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
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<string>();
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<string, string> },
) =>
@@ -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)
@@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
listImportableSessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
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<AgentStorage, "list">;
} satisfies Pick<AgentStorage, "listAll">;
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<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Codex" },
});
@@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Pi" },
});
@@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
listImportableSessions: async () => makeImportableSessionsResult([]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "" },
}),
).rejects.toMatchObject(
@@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error {
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
agentStorage: Pick<AgentStorage, "list">;
agentStorage: Pick<AgentStorage, "listAll">;
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
}
@@ -342,12 +342,13 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
async function collectImportedProviderSessions(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
agentStorage: Pick<AgentStorage, "listAll">,
providerFilter: Set<string> | undefined,
): Promise<{ handles: Set<string>; count: number }> {
const handles = new Set<string>();
const sessions = new Set<string>();
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 = (
@@ -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<string, string>(),
remove: vi.fn(),
};
}
@@ -639,7 +641,16 @@ class BoundaryAgentManagerFake {
}
class BoundaryAgentStorageFake {
public async list(): Promise<StoredAgentRecord[]> {
public async listActive(): Promise<StoredAgentRecord[]> {
return [];
}
async listByWorkspaces() {
return [];
}
async lastUserMessageAtByWorkspace() {
return new Map<string, string>();
}
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,
@@ -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 });
}
@@ -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 });
}
@@ -138,7 +138,7 @@ export interface PaseoToolHostDependencies {
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "listActive" | "upsert">;
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
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),
)
@@ -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,
@@ -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);
+7 -5
View File
@@ -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<string | null> => {
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<ActiveWorkspaceRef[]> => {
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`,
@@ -840,7 +840,7 @@ export class HubRelationshipHarness {
}
async durableOwnedAgentIds(): Promise<string[]> {
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,
};
}
@@ -68,8 +68,10 @@ export async function backfillWorkspaceIdForLegacyAgents(options: {
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
}): Promise<number> {
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) {
@@ -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);
@@ -18,7 +18,7 @@ function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
interface BuildSessionConfigOptions {
@@ -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", [
@@ -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;
}
@@ -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({
+2 -2
View File
@@ -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(),
+38 -18
View File
@@ -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<string>();
for (const record of storedRecords) {
@@ -5583,7 +5595,7 @@ export class Session {
Map<string, ProjectPlacementPayload>
> {
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<string | null> {
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<ActiveWorkspaceRef[]> {
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<void> {
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<string, PersistedWorkspaceRecord>();
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)
@@ -208,7 +208,10 @@ function createSessionForWorkspaceGitWatchTests(options?: {
getAgent: () => null,
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
list: async () => [],
listAll: async () => [],
listActive: async () => [],
listByWorkspaces: async () => [],
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
get: async () => null,
}),
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
@@ -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<typeof createPersistedWorkspaceRecord>) => {
workspaces.set(record.workspaceId, record);
@@ -115,7 +115,10 @@ function createHarness(input: {
notifyAgentState: () => {},
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
list: async () => [],
listAll: async () => [],
listActive: async () => [],
listByWorkspaces: async () => [],
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
get: async () => null,
}),
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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] : []);
},
);
@@ -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<void> {
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<PersistedWorkspaceRecord> {
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),
)
@@ -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);
@@ -45,6 +45,19 @@ export function asAgentStorage(stub: {
[K in keyof SessionOptions["agentStorage"]]?: unknown;
}): SessionOptions["agentStorage"] {
return createStub<SessionOptions["agentStorage"]>({
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<string, string>(),
listByProviderSession: async () => [],
listByWorkspace: async () => [],
...stub,
@@ -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 () => {},
+17 -2
View File
@@ -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<string, string>();
}
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(
@@ -973,8 +973,9 @@ describe("archiveByScope", () => {
}),
};
deps.agentStorage = {
list: async () => [{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[],
} as Pick<AgentStorage, "list">;
listByWorkspace: async () =>
[{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[],
} as Pick<AgentStorage, "listByWorkspace">;
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
@@ -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<string, string>();
@@ -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);
@@ -74,10 +74,13 @@ export interface WorkspaceDirectoryDeps {
list(): Promise<PersistedProjectRecord[]>;
};
workspaceRegistry: {
list(): Promise<PersistedWorkspaceRecord[]>;
listActive(): Promise<PersistedWorkspaceRecord[]>;
get(id: string): Promise<PersistedWorkspaceRecord | null>;
};
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
workspaceLastUserMessageTimes(): Promise<ReadonlyMap<string, string>>;
listAgentPayloads(workspaceIds?: Iterable<string>): Promise<AgentSnapshotPayload[]>;
workspaceLastUserMessageTimes(
workspaceIds?: Iterable<string>,
): Promise<ReadonlyMap<string, string>>;
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
listTerminalActivityContributions(): Promise<
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
@@ -217,6 +220,7 @@ export class WorkspaceDirectory {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
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<string, WorkspaceDescriptorPayload>();
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<WorkspaceProjectDescriptor[]> {
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<WorkspaceDescriptorPayload, "id" | "workspaceDirectory" | "workspaceKind">[]
> {
const [workspaces, projects] = await Promise.all([
this.deps.workspaceRegistry.list(),
this.deps.workspaceRegistry.listActive(),
this.deps.projectRegistry.list(),
]);
return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({
@@ -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();
}
}
@@ -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<string, PersistedWorkspaceRecord[]>();
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);
}
@@ -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);
@@ -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.
@@ -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 () => {
@@ -156,7 +156,8 @@ export interface ProjectRegistry {
export interface WorkspaceRegistry {
initialize(): Promise<void>;
existsOnDisk(): Promise<boolean>;
list(): Promise<PersistedWorkspaceRecord[]>;
listAll(): Promise<PersistedWorkspaceRecord[]>;
listActive(): Promise<PersistedWorkspaceRecord[]>;
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
update(
workspaceId: string,
@@ -182,6 +183,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
protected readonly logger: Logger;
private readonly schema: z.ZodType<TRecord, unknown>;
private readonly getId: (record: TRecord) => string;
private readonly activeIds = new Set<string>();
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private mutationQueue: Promise<void> = Promise.resolve();
@@ -219,11 +221,16 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
}
}
async list(): Promise<TRecord[]> {
async listAll(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async listActive(): Promise<TRecord[]> {
await this.load();
return Array.from(this.activeIds, (id) => this.cache.get(id)!);
}
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(id) ?? null;
@@ -288,6 +295,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
}
}
this.rebuildActiveIndex();
this.loaded = true;
}
@@ -335,6 +343,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
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<TRecord extends RegistryRecord> {
}
}
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<PersistedProjectRecord>
implements ProjectRegistry
{
async list(): Promise<PersistedProjectRecord[]> {
return this.listAll();
}
private allocationQueue: Promise<void> = Promise.resolve();
private readonly projectIdFactory: () => string;
private readonly mutationListeners = new Set<
@@ -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<AgentStorage, "list"> {
function createAgentStorageStub(): Pick<AgentStorage, "listAll"> {
return {
list: async (): Promise<StoredAgentRecord[]> => [],
listAll: async (): Promise<StoredAgentRecord[]> => [],
};
}