mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(agent): return an AgentHandle with an async per-agent disposer
The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.
The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
1. runs the register+start effect disposer (sync: request loop stop),
2. `await agent.done` (loop exits, final flush captured), THEN
3. runs the session disposer (detach onAppend + delete store entry).
`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.
`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
This commit is contained in:
@@ -11,7 +11,7 @@ import { Context, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -120,23 +120,28 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
*/
|
||||
create(id: string, options: AgentOptions = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
// Config/programmatic path: the session is owned by THIS fiber (the plain
|
||||
// create()), so disposing the AgentLoop/caller fiber removes it. No
|
||||
// AgentHandle is needed — the register+start effect is fiber-owned too.
|
||||
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
|
||||
return this.start(AgentId(id), options, session)
|
||||
const { agent } = this.start(AgentId(id), options, session)
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic factory create ({@link AgentFactory}): an agent on a
|
||||
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
|
||||
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
|
||||
* client-generated session id becomes the live/persisted session id.
|
||||
* client-generated session id becomes the live/persisted session id. Returns
|
||||
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): Agent {
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE creating the session: register() would reject a
|
||||
// duplicate id only AFTER sessions.create(), leaving an orphaned live
|
||||
// session (and lazy persistence state) that blocks reuse of that id.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} })
|
||||
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
|
||||
const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} })
|
||||
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +156,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<Agent> {
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
@@ -183,7 +188,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* sessions store + registry are still read through `this.ctx` (both are in
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<Agent> {
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
|
||||
// Re-check the agent id AFTER the await: the pre-load check above can go
|
||||
@@ -196,7 +201,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// events make lastTurnNumber/deriveMessages continue; the backend already
|
||||
// has state (cursor) from the load above, so onCreated is a no-op and the
|
||||
// seed is not re-persisted.
|
||||
const session = this.ctx.sessions.create(options.resumeSessionId, {
|
||||
const owned = this.ctx.sessions.createOwned(options.resumeSessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
@@ -204,7 +209,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
},
|
||||
})
|
||||
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
|
||||
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,16 +224,54 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
|
||||
private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
|
||||
/**
|
||||
* Shared: construct a ReactLoopAgent, register it, and start its loop. The
|
||||
* register + loop-stop disposers live in ONE generator effect so they run
|
||||
* LIFO on dispose (the loop-stop disposer — yielded last — runs first, then
|
||||
* the registry unregister), so a throwing stop() cannot leak the registry
|
||||
* entry. Returns the agent plus the effect's disposer (`disposeAgent`); the
|
||||
* effect is owned by the caller fiber, so disposing that fiber also tears the
|
||||
* agent down — the disposer is for an OWNER that needs to tear down ONE agent.
|
||||
*/
|
||||
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
// Generator effect: stop and unregister are independent disposables
|
||||
// (LIFO), so a throwing stop() cannot leak the registry entry.
|
||||
this.ctx.effect(function* (this: AgentLoop) {
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
yield this.ctx.agents.register(agent)
|
||||
yield agent.start()
|
||||
}.bind(this), 'agentLoop.start()')
|
||||
return agent
|
||||
return { agent, disposeAgent: async () => { await dispose() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link AgentHandle} for an OWNED session + agent. The handle's
|
||||
* `dispose()` tears down exactly this agent in the order durability requires:
|
||||
*
|
||||
* 1. run `disposeAgent` — the register+start effect's disposer. LIFO runs
|
||||
* `agent.start()`'s (synchronous) disposer first: it sets `disposed`,
|
||||
* aborts the in-flight step, and unblocks the loop's idle wait. Then the
|
||||
* registry unregister runs. The loop has NOT necessarily exited yet — the
|
||||
* start disposer only REQUESTS exit, it does not await it.
|
||||
* 2. `await agent.done` — the loop-exit promise. The loop unwinds and runs
|
||||
* its final `session/flush` + `turn/end`, delivered through the still-
|
||||
* attached `session.onAppend` → `session/event`, so persistence captures
|
||||
* the closing events. Only now is the agent truly quiescent.
|
||||
* 3. run the session disposer — detach `onAppend` and remove the store
|
||||
* entry. Done LAST so step 2's final flush is not dropped.
|
||||
*/
|
||||
private startOwned(
|
||||
id: AgentId,
|
||||
options: AgentOptions,
|
||||
owned: { session: Session; dispose: () => Promise<void> },
|
||||
): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, owned.session)
|
||||
return {
|
||||
agent,
|
||||
dispose: async () => {
|
||||
await disposeAgent() // stop the loop (sync) + unregister
|
||||
await agent.done // wait for the loop to actually exit (final flush captured)
|
||||
await owned.dispose() // detach onAppend + remove the session store entry
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const agent = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
|
||||
const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -63,7 +63,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent works without meta (no cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const agent = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
|
||||
const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent
|
||||
expect(a2.session.header.cwd).toBeUndefined()
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
await ctx2.fiber.dispose()
|
||||
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// disk, since a crash before the next turn would otherwise lose it.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// drop it on reload (the bug this guards).
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent
|
||||
const flat = JSON.stringify(a2.session.deriveMessages())
|
||||
expect(flat).toContain('background task 42 finished')
|
||||
await ctx2.fiber.dispose()
|
||||
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
|
||||
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
|
||||
@@ -54,6 +54,23 @@ export interface ResumeAgentOptions {
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
|
||||
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
|
||||
* removes the agent's session from the store, in an order that captures the
|
||||
* loop's final `session/flush` before the session is detached.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
|
||||
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||
* are owned by the loop fiber and never need a handle.
|
||||
*/
|
||||
export interface AgentHandle {
|
||||
agent: Agent
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent-creation factory the loop implementation provides to the registry
|
||||
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
|
||||
@@ -61,14 +78,18 @@ export interface ResumeAgentOptions {
|
||||
* depending on the concrete `dsh-agent-loop` package.
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/** Create, start, and register a new agent on a caller-supplied session id. */
|
||||
createAgent(options: CreateAgentOptions): Agent
|
||||
/**
|
||||
* Create, start, and register a new agent on a caller-supplied session id.
|
||||
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
|
||||
* this agent (unregister + stop loop + await quiescence + remove session).
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* `ctx.sessionPersistence.load`; must be called after that service exists
|
||||
* (consumers inject `sessionPersistence`).
|
||||
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
|
||||
*/
|
||||
resume(options: ResumeAgentOptions): Promise<Agent>
|
||||
resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
}
|
||||
|
||||
/** Thrown when create/resume is called before an agent factory is registered. */
|
||||
@@ -107,9 +128,10 @@ export class AgentRegistry extends Service {
|
||||
* Create, start, and register a new agent through the registered factory.
|
||||
* Distinct from {@link register} (which records an already-constructed
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered.
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
*/
|
||||
create(options: CreateAgentOptions): Agent {
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.createAgent(options)
|
||||
}
|
||||
@@ -117,9 +139,9 @@ export class AgentRegistry extends Service {
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured.
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<Agent> {
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.resume(options)
|
||||
}
|
||||
|
||||
@@ -82,8 +82,14 @@ describe('AgentRegistry factory seam', () => {
|
||||
function stubFactory() {
|
||||
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
||||
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
||||
createAgent(options) { calls.create.push(options); return stubAgent(options.agentId) },
|
||||
resume(options) { calls.resume.push(options); return Promise.resolve(stubAgent(options.agentId)) },
|
||||
createAgent(options) {
|
||||
calls.create.push(options)
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume(options) {
|
||||
calls.resume.push(options)
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
},
|
||||
}
|
||||
return { factory, calls }
|
||||
}
|
||||
@@ -102,11 +108,11 @@ describe('AgentRegistry factory seam', () => {
|
||||
ctx.agents.setFactory(factory)
|
||||
|
||||
const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } })
|
||||
expect(created.id).toBe('c1')
|
||||
expect(created.agent.id).toBe('c1')
|
||||
expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }])
|
||||
|
||||
const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' })
|
||||
expect(resumed.id).toBe('r1')
|
||||
expect(resumed.agent.id).toBe('r1')
|
||||
expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }])
|
||||
})
|
||||
|
||||
|
||||
@@ -230,6 +230,25 @@ export class SessionStore extends Service {
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
create(id?: string, options?: CreateSessionOptions): Session {
|
||||
// Discard the store-removal disposer: a plain create() is owned by the
|
||||
// calling fiber (disposing the fiber removes the session). An owner that
|
||||
// needs to remove ONE session independently uses createOwned().
|
||||
return this.createOwned(id, options).session
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link create}, but ALSO returns the disposer for the session's
|
||||
* store-removal effect — so an owner can remove exactly THIS session (detach
|
||||
* `onAppend`, delete the store entry) without disposing the whole fiber.
|
||||
*
|
||||
* Used by the agent factory's {@link AgentHandle} teardown: an owned agent's
|
||||
* `dispose()` stops the loop, awaits quiescence, unregisters the agent, and
|
||||
* THEN runs this session disposer — so the loop's final `session/flush`
|
||||
* (delivered via `onAppend` → `session/event`) is captured before `onAppend`
|
||||
* is detached. The disposer is async (a cordis effect disposer) to compose
|
||||
* with the agent teardown's promise chain.
|
||||
*/
|
||||
createOwned(id?: string, options?: CreateSessionOptions): { session: Session; dispose: () => Promise<void> } {
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const cwd = options?.meta?.cwd
|
||||
@@ -244,7 +263,7 @@ export class SessionStore extends Service {
|
||||
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
|
||||
}
|
||||
const session = new Session(sessionId, options?.seed, header)
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
const dispose = this.ctx.effect(function* (this: SessionStore) {
|
||||
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||
this.store.set(sessionId, session)
|
||||
// Yield the rollback BEFORE emitting `session/created`: a generator
|
||||
@@ -259,7 +278,9 @@ export class SessionStore extends Service {
|
||||
}
|
||||
this.ctx.emit('session/created', session)
|
||||
}.bind(this), 'sessions.create()')
|
||||
return session
|
||||
// ctx.effect's disposer returns Promise<void>; normalize to an always-async
|
||||
// disposer for the owner.
|
||||
return { session, dispose: async () => { await dispose() } }
|
||||
}
|
||||
|
||||
get(id: string): Session | undefined {
|
||||
|
||||
Reference in New Issue
Block a user