fix(subagent): close continuation lifecycle gaps
This commit is contained in:
@@ -31,10 +31,9 @@ import {
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
} from '@deepseek-ai/dsh-hook-protocol'
|
||||
// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event
|
||||
// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the
|
||||
// SubagentStart/SubagentStop listeners below type-check.
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
// Pulls in the declaration-merged subagent events and the identity pairing their
|
||||
// start/end edges.
|
||||
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
|
||||
|
||||
export const name = 'hooks-claude'
|
||||
@@ -119,6 +118,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Emit-shaped points run detached, so track their chains; disposal aborts
|
||||
// active hooks and drains continuations before resolving.
|
||||
const detached = createDetachedRuns()
|
||||
// Only the start edge guarantees registry access. Retain each local child
|
||||
// through its paired end so stop hooks keep the session workspace after the
|
||||
// handle unregisters the agent.
|
||||
const subagentChildren = new Map<SubagentRunId, Agent>()
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
|
||||
|
||||
/**
|
||||
@@ -276,6 +279,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// use the live child's workspace and the generic agent-type matcher subject.
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
if (child !== undefined) subagentChildren.set(info.runId, child)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
@@ -284,10 +288,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
|
||||
})
|
||||
ctx.on('subagent/end', (info) => {
|
||||
// Look up the child (still recoverable: `subagent/end` fires from the service's detached
|
||||
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
|
||||
// child's cwd, not the server default.
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)
|
||||
subagentChildren.delete(info.runId)
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -681,12 +681,11 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
|
||||
// receives that agent and runs in the child's cwd rather than the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
|
||||
const payload = join(childDir, 'stoppayload')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
@@ -698,15 +697,22 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
|
||||
const runId = SubagentRunId('run-stop')
|
||||
const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true }
|
||||
// Start is the registry-backed capture edge; end deliberately follows
|
||||
// handle disposal, matching continuable Activation settlement.
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', identity)
|
||||
await childHandle.dispose()
|
||||
expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined()
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string }
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
|
||||
await childHandle.dispose()
|
||||
expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user