From c2979322c8b938b1c2e91920740688788867e6d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:13:42 +0800 Subject: [PATCH] fix: keep stdio targeting on runtime roots --- docs/cordis-catalog/services.md | 5 ++-- packages/core/agent-loop/src/index.ts | 2 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 18 +++++++++++++ packages/core/agent/README.md | 3 ++- packages/core/agent/src/index.ts | 22 ++++++++++++++-- packages/core/agent/tests/agent.spec.ts | 24 +++++++++++++++--- packages/ui/stdio-agent/src/stdio-chat.ts | 20 +++++++-------- .../ui/stdio-agent/tests/readline.spec.ts | 4 +-- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 25 ++++++++++++------- 9 files changed, 92 insertions(+), 31 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d162a55f5..1366fcf6f6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -30,15 +30,16 @@ setFactory(factory: AgentFactory): () => void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void -enter(agent: Agent): () => void +enter(agent: Agent, owner: Agent | undefined): () => void announce(agent: Agent): void get(id: SessionId): Agent | undefined list(): Agent[] +roots(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:199`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33276c01f8..dbe503ff6a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -229,7 +229,7 @@ class AgentCreationTransaction { this.publishing = true try { this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent) + this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) agent.ctx.sessions.announce(session) this.assertActive() diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index e29e73c393..610e077a67 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -149,6 +149,24 @@ describe('agent scope lifecycle', () => { await ctx.agents.get(SessionId('a1'))?.whenIdle() }) + it('records agents created through an agent context as non-root runtime children', async () => { + const ctx = await harness() + const root = await ctx.agents.create({ + sessionId: SessionId('runtime-root'), + agentOptions: { model: 'mock' }, + }) + const child = await root.agent.ctx.agents.create({ + sessionId: SessionId('runtime-child'), + agentOptions: { model: 'mock' }, + }) + + expect(ctx.agents.list()).toEqual([root.agent, child.agent]) + expect(ctx.agents.roots()).toEqual([root.agent]) + + await child.dispose() + await root.dispose() + }) + it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 4eddb050d5..643c15002b 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -11,9 +11,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `enter(agent, owner): () => void` performs the authoritative ID collision check and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: SessionId): Agent | undefined` - `ctx.agents.list(): Agent[]` +- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. #### Factory seam (creation) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 1306e8004d..b25d1e3cac 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -178,6 +178,8 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug interface AgentEntry { readonly id: SessionId readonly agent: Agent + /** Runtime creator-agent ownership; independent of durable session lineage. */ + readonly owner: Agent | undefined readonly carrier: Scoped announced: boolean announcing: boolean @@ -303,7 +305,7 @@ export class AgentRegistry extends Service { */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent) + yield this.enter(agent, this.ctx.agent) this.announce(agent) }.bind(this), 'agents.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity @@ -317,12 +319,15 @@ export class AgentRegistry extends Service { * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * synchronous `agent/created` listener, removal and disposal wait until * that creation dispatch unwinds. */ - enter(agent: Agent): () => void { + enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id const carrier = scopeTarget(agent, agent) // This is the authoritative collision boundary. Concurrent create/resume @@ -331,6 +336,7 @@ export class AgentRegistry extends Service { const entry: AgentEntry = { id, agent, + owner, carrier, announced: false, announcing: false, @@ -438,6 +444,18 @@ export class AgentRegistry extends Service { list(): Agent[] { return [...this.store.values()].map(entry => entry.agent) } + + /** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ + roots(): Agent[] { + return [...this.store.values()] + .filter(entry => entry.owner === undefined) + .map(entry => entry.agent) + } } export default AgentRegistry diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index cba833b212..5563d01cad 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -42,6 +42,7 @@ describe('AgentRegistry', () => { const dispose = ctx.agents.register(agent) expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) + expect(ctx.agents.roots()).toEqual([agent]) expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) dispose() @@ -49,6 +50,23 @@ describe('AgentRegistry', () => { expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) + it('tracks runtime creator ownership separately from registry order', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const root = stubAgent('root') + const child = stubAgent('child') + const detachRoot = ctx.agents.enter(root, undefined) + ctx.agents.announce(root) + const detachChild = ctx.agents.enter(child, root) + ctx.agents.announce(child) + + expect(ctx.agents.list()).toEqual([root, child]) + expect(ctx.agents.roots()).toEqual([root]) + + detachChild() + detachRoot() + }) + it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -94,7 +112,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') - const detachFirst = ctx.agents.enter(first) + const detachFirst = ctx.agents.enter(first, undefined) expect(lifecycle).toEqual([]) ctx.agents.announce(first) expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) @@ -102,7 +120,7 @@ describe('AgentRegistry', () => { detachFirst() const replacement = stubAgent('split') - const detachReplacement = ctx.agents.enter(replacement) + const detachReplacement = ctx.agents.enter(replacement, undefined) detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) @@ -122,7 +140,7 @@ describe('AgentRegistry', () => { }) ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) ctx.on('agent/disposed', () => void order.push('disposed')) - const detach = ctx.agents.enter(agent) + const detach = ctx.agents.enter(agent, undefined) ctx.agents.announce(agent) expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 2a4d620494..a295274786 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -95,18 +95,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns one configured agent. Hold the live object directly: its - // per-run id is intentionally fresh, while `main` remains only the - // terminal's fixed display label. At install the configured agent is the - // earliest registry entry (it creates any subagents later). During HMR the - // replacement is published after the old tree, so when old teardown finally - // emits disposed, the newest survivor is the replacement. Persisted - // parentSession lineage is deliberately irrelevant: a resumed child session - // can itself be this process's configured top-level agent. - let target: Agent | undefined = ctx.agents.list()[0] - ctx.on('agent/created', (agent) => { target ??= agent }) + // This app owns one configured top-level agent. Hold the live object + // directly: its per-run id is intentionally fresh, while `main` remains only + // the terminal's fixed display label. Runtime creator ownership distinguishes + // that root from its subagents even if a child is registered after an HMR + // replacement. Persisted parentSession lineage is deliberately irrelevant: + // a resumed child session can itself be this process's configured root. + let target: Agent | undefined = ctx.agents.roots()[0] + ctx.on('agent/created', () => { target ??= ctx.agents.roots()[0] }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = ctx.agents.list().at(-1) + if (target === agent) target = ctx.agents.roots().at(-1) }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts index 6116aa356f..fa82db4d96 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -16,9 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its target object from the registry at install; this suite only + // The UI seeds its root target from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { list: vi.fn(() => []) }, + agents: { roots: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, } as unknown as Context } diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2654a05680..2600fdb24f 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -154,8 +154,7 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created supplies the app-owned target object. - ctx.emit('agent/created', agent) + ctx.agents.register(agent) const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, @@ -203,7 +202,7 @@ describe('createStdioChat rendering', () => { const { ctx, input } = await setup() const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.emit('agent/created', resumed) + ctx.agents.register(resumed) input.feed('continue') await new Promise(resolve => setImmediate(resolve)) @@ -224,8 +223,8 @@ describe('createStdioChat rendering', () => { it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/created', agent) - ctx.emit('agent/disposed', agent) + const dispose = ctx.agents.register(agent) + dispose() // After disposal the event belongs to a non-target session, so its durable // identity is rendered directly. ctx.emit('session/event', agent.session, { @@ -237,7 +236,7 @@ describe('createStdioChat rendering', () => { it('keeps the target when a different agent is disposed', async () => { const { ctx, out } = await setup() const target = makeAgent('target') - ctx.emit('agent/created', target) + ctx.agents.register(target) ctx.emit('agent/disposed', makeAgent('other')) ctx.emit('session/event', target.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, @@ -251,19 +250,27 @@ describe('createStdioChat rendering', () => { const child = makeAgent('child') ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id const replacement = makeAgent('replacement') + const lateChild = makeAgent('late-child') const disposeOld = ctx.agents.register(oldRoot) - ctx.agents.register(child) + const disposeChild = ctx.agents.enter(child, oldRoot) + ctx.agents.announce(child) ctx.agents.register(replacement) + const disposeLateChild = ctx.agents.enter(lateChild, replacement) + ctx.agents.announce(lateChild) // The replacement's created edge arrived while oldRoot was still targeted. - // Once oldRoot is removed, registry order is child then replacement; the - // most recently published survivor is the HMR replacement. + // A replacement-owned child then arrived even later. Once oldRoot is + // removed, runtime ownership still identifies replacement as the only + // surviving root instead of selecting either newer child by insertion order. disposeOld() input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) expect(child.sent).toEqual([]) + expect(lateChild.sent).toEqual([]) expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) + disposeLateChild() + disposeChild() }) it('renders tool/call and tool/result session events', async () => {