Просмотр исходного кода

Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
Tianyi Cui 2 месяцев назад
Родитель
Сommit
7d30906ee4

+ 1 - 1
docs/config-catalog.md

@@ -131,7 +131,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
 
-Source: [`packages/core/agent-loop/src/index.ts:323`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:332`](../packages/core/agent-loop/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-local`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -21,7 +21,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:347`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 

+ 2 - 0
docs/core-data-structures/subagent.md

@@ -76,6 +76,8 @@ interface SubagentRun {
 }
 ```
 
+A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child.
+
 ## The provider seam: `SubagentProvider`
 
 One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance.

+ 15 - 4
packages/core/agent-loop/src/index.ts

@@ -44,6 +44,7 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
 class FactoryOwnership {
   private accepting = true
   private transactions = new Set<AgentCreationTransaction>()
+  private startupTasks = new Set<Promise<void>>()
 
   constructor(private readonly fiber: Context['fiber']) {}
 
@@ -56,12 +57,20 @@ class FactoryOwnership {
     return () => { this.transactions.delete(transaction) }
   }
 
+  /** Join config startup work that begins before an agent transaction exists. */
+  trackStartup(task: Promise<void>): void {
+    this.startupTasks.add(task)
+    const forget = () => { this.startupTasks.delete(task) }
+    void task.then(forget, forget)
+  }
+
   async dispose(): Promise<void> {
     this.accepting = false
     const reason = new Error('agent loop is not active')
-    await Promise.all(
-      [...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
-    )
+    await Promise.all([
+      ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
+      ...this.startupTasks,
+    ])
   }
 }
 
@@ -370,9 +379,10 @@ export class AgentLoop extends Service implements AgentFactory {
         if (persistence === undefined) {
           this.create(configuredId, options, meta)
         } else {
-          void this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
+          const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
             ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`)
           })
+          this.ownership.trackStartup(startup)
         }
         continue
       }
@@ -402,6 +412,7 @@ export class AgentLoop extends Service implements AgentFactory {
     meta: Pick<SessionHeader, 'cwd'>,
   ): Promise<void> {
     const exists = (await persistence.list()).some(header => header.id === sessionId)
+    if (!this.ownership.isActive()) return
     if (exists) {
       await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
       return

+ 25 - 0
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -113,6 +113,31 @@ describe('config-driven session id', () => {
     await ctx.fiber.dispose()
   })
 
+  it('joins an exact-id persistence lookup before AgentLoop disposal completes', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
+    dirs.push(root)
+    const ctx = await makeCoreContext()
+    await ctx.plugin(SessionPersistenceJsonl, { root })
+    const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
+    vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+
+    const loop = await ctx.plugin(AgentLoop, {
+      agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }],
+    })
+    let disposed = false
+    const disposal = loop.dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+
+    listing.resolve([])
+    await disposal
+    expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined()
+    expect(warn).not.toHaveBeenCalled()
+    warn.mockRestore()
+    await ctx.fiber.dispose()
+  })
+
   it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)