diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 9f095599a..6546ecbf9 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -83,8 +83,8 @@ export interface ISessionLifecycleService { * agent from the persisted wire log. Returns the existing handle when the * session is already live (a no-op in that case — live agents are never * re-restored). Returns `undefined` when the session is unknown to the index - * or its workspace is no longer registered (mirrors the cold-source - * limitation of `fork`). + * or neither the persisted session summary nor the workspace registry can + * provide a workdir (mirrors the cold-source limitation of `fork`). * * Lets the read edges (snapshot / messages) serve cold sessions — created by * a previous process or by v1 — without requiring a prior `create` in this diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 47293b234..14e1396c7 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -8,7 +8,8 @@ * its `agentLifecycle` agents, and * broadcasts through `event`. Materializes the session's initial metadata on * creation by resolving `sessionMetadata`. Bound at App scope. Persisted - * sessions are the `sessionIndex` read model. + * sessions are discovered through the `sessionIndex` read model, and workspace + * roots are remembered through `workspaceRegistry`. */ import { randomUUID } from 'node:crypto'; @@ -23,7 +24,6 @@ import { } from '#/_base/di/scope'; import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -63,6 +63,10 @@ import { ISessionLifecycleService, } from './sessionLifecycle'; +type MaterializeSessionOptions = CreateSessionOptions & { + readonly workspaceId?: string; +}; + export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); @@ -102,8 +106,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } - private async materializeSession(opts: CreateSessionOptions): Promise { - const workspaceId = encodeWorkDirKey(opts.workDir); + private async materializeSession(opts: MaterializeSessionOptions): Promise { + const workspace = await this.workspaceRegistry.createOrTouch(opts.workDir); + const workspaceId = opts.workspaceId ?? workspace.id; const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); // Metadata lives at `/state.json` (shared with v1's layout; the @@ -169,10 +174,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const summary = await this.index.get(sessionId); if (summary === undefined) return undefined; - const workspace = await this.workspaceRegistry.get(summary.workspaceId); - if (workspace === undefined) return undefined; + const workspace = + summary.cwd === undefined + ? await this.workspaceRegistry.get(summary.workspaceId) + : undefined; + const workDir = summary.cwd ?? workspace?.root; + if (workDir === undefined) return undefined; - const handle = await this.materializeSession({ sessionId, workDir: workspace.root }); + const handle = await this.materializeSession({ + sessionId, + workDir, + workspaceId: summary.workspaceId, + }); const agents = handle.accessor.get(IAgentLifecycleService); if (agents.getHandle(MAIN_AGENT_ID) === undefined) { const main = await ensureMainAgent(handle); diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index e0995f1ef..473474b2e 100644 --- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts @@ -26,6 +26,9 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent'; function bootstrapStub(): IBootstrapService { return { @@ -111,6 +114,34 @@ function workspaceRegistryStub(): IWorkspaceRegistry { }; } +function persistentWorkspaceRegistryStub(): IWorkspaceRegistry { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const now = 1; + const existing = workspaces.get(id); + const workspace: Workspace = + existing !== undefined + ? { ...existing, lastOpenedAt: now } + : { + id, + root, + name: name ?? 'proj', + createdAt: now, + lastOpenedAt: now, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + function sessionIndexStub(): ISessionIndex { return { _serviceBrand: undefined, @@ -120,6 +151,27 @@ function sessionIndexStub(): ISessionIndex { }; } +function sessionIndexWithSummary( + sessionId: string, + workDir: string, + workspaceId = encodeWorkDirKey(workDir), +): ISessionIndex { + const summary = { + id: sessionId, + workspaceId, + cwd: workDir, + createdAt: 1, + updatedAt: 1, + archived: false, + }; + return { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [summary], total: 1, hasMore: false }), + get: (id) => Promise.resolve(id === sessionId ? summary : undefined), + countActive: () => Promise.resolve(1), + }; +} + function appendLogStoreStub(): IAppendLogStore { return { _serviceBrand: undefined, @@ -167,6 +219,23 @@ function agentLifecycleStub(): IAgentLifecycleService { }; } +function agentLifecycleWithMainStub(): IAgentLifecycleService { + const main = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + return { + ...agentLifecycleStub(), + getHandle: (id) => (id === MAIN_AGENT_ID ? main : undefined), + }; +} + function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -263,6 +332,91 @@ describe('SessionLifecycleService', () => { expect(h.kind).toBe(LifecycleScope.Session); }); + it('registers the workspace during create so a cold resume can resolve the workdir', async () => { + const workDir = '/tmp/proj'; + const workspaceRegistry = persistentWorkspaceRegistryStub(); + const sessionIndex = sessionIndexWithSummary('s1', workDir); + const first = build([ + stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(ISessionIndex, sessionIndex), + ]); + + await first.create({ sessionId: 's1', workDir }); + await expect(workspaceRegistry.get(encodeWorkDirKey(workDir))).resolves.toMatchObject({ + root: workDir, + }); + host?.dispose(); + host = undefined; + + const second = build([ + stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(ISessionIndex, sessionIndex), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + const resumed = await second.resume('s1'); + + expect(resumed?.id).toBe('s1'); + expect(resumed?.accessor.get(ISessionContext).cwd).toBe(workDir); + }); + + it('resumes from the persisted cwd when the workspace registry entry is missing', async () => { + const workDir = '/tmp/proj'; + const svc = build([ + stubPair(IWorkspaceRegistry, persistentWorkspaceRegistryStub()), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + + const resumed = await svc.resume('s1'); + + expect(resumed?.id).toBe('s1'); + expect(resumed?.accessor.get(ISessionContext).workspaceId).toBe(encodeWorkDirKey(workDir)); + }); + + it('resumes with the persisted cwd and indexed workspace id when the registry root is stale', async () => { + const workDir = '/tmp/proj'; + const staleRoot = '/tmp/stale'; + const indexedWorkspaceId = 'wd_indexed'; + const workspaceRegistry: IWorkspaceRegistry = { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + get: (id) => + Promise.resolve( + id === indexedWorkspaceId + ? { + id: indexedWorkspaceId, + root: staleRoot, + name: 'stale', + createdAt: 1, + lastOpenedAt: 1, + } + : undefined, + ), + createOrTouch: (root, name) => + Promise.resolve({ + id: encodeWorkDirKey(root), + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }), + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; + const svc = build([ + stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir, indexedWorkspaceId)), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + + const resumed = await svc.resume('s1'); + const ctx = resumed?.accessor.get(ISessionContext); + + expect(ctx?.cwd).toBe(workDir); + expect(ctx?.workspaceId).toBe(indexedWorkspaceId); + expect(ctx?.sessionDir).toBe(`/tmp/sessions/${indexedWorkspaceId}/s1`); + }); + it('archive flags metadata, removes agents, publishes the event, and disposes the session', async () => { let archived: boolean | undefined; const removed: string[] = [];