fix(hooks): drain detached hook runs on bridge dispose

The emit-shaped hook points (SessionStart, SubagentStart, SubagentStop)
run fire-and-forget: no seam awaits the run chain, so disposing a bridge
could strand a live hook process and let a late continuation inject into
a disposed context. The floating continuation also made the coverage
gate racy: the only coverage of the SubagentStart continuation's
no-context branch arm rode on an un-awaited .then, and on a loaded CI
runner the fork's per-file coverage snapshot beat it — master run
28798191671 failed the 100% branch gate on hooks-claude/src/index.ts at
99.03% (uncovered line 336) with the identical tree passing the PR run
three minutes earlier.

New shared primitive createDetachedRuns() in dsh-hook-protocol: a bridge
tracks each detached run chain, passes the tracker's abort signal to
runHook, and registers drain() as its effect disposer — drain aborts
still-running hook processes (a kill via the bash seam, not a wait out
to the 10-minute default hook timeout), then resolves once every chain
has settled. fiber.dispose() resolving now means the bridge's detached
work is quiescent (docs/defensive-patterns.md).

The subagent marker test disposes the bridge as its sync point, so the
formerly racy branch arm is executed deterministically before the file's
coverage snapshot; new tests pin abort-on-dispose promptness for both
bridges and the tracker's settle/drain contract in hook-protocol.
This commit is contained in:
Tianyi Cui
2026-07-06 23:27:54 +08:00
parent 0606cd559c
commit 9ca31ab193
11 changed files with 260 additions and 19 deletions

View File

@@ -42,6 +42,8 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
| `SubagentStop` | `subagent/end` (emit) | observe-only |
The three emit points run detached — no seam awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
## Context source

View File

@@ -31,6 +31,7 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
import {
appendHookInvoked,
appendHookResult,
createDetachedRuns,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
@@ -128,6 +129,14 @@ export function apply(ctx: Context, config: Config): void {
return
}
// --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run
// detached — no seam awaits them — so every run chain is tracked and disposal
// aborts still-running hook processes, then drains the continuations
// (docs/defensive-patterns.md: dispose must reach quiescence). After the parse
// gate: a bridge that registered nothing has nothing to drain. ---
const detached = createDetachedRuns()
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
@@ -237,14 +246,14 @@ export function apply(ctx: Context, config: Config): void {
// to the interception seams; today the contract is "injected as soon as the
// hook resolves", not "before the first request". ---
ctx.on('agent/session-start', (agent, source) => {
void runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent })
detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => {
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
})
}))
})
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
@@ -330,12 +339,12 @@ export function apply(ctx: Context, config: Config): void {
// a specific-kind matcher does not (documented in the RFC). ---
ctx.on('subagent/start', (info) => {
const child = ctx.get('agents')?.get(info.id)
void runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {} })
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context && child) child.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })
.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
@@ -343,9 +352,10 @@ export function apply(ctx: Context, config: Config): void {
// disposes it) so the hook runs in the child's cwd, not the server default.
// No `.then`/inject follows (SubagentStop only observes), and no `turn` is
// passed (so no `hook/*` log records), so runPoint has nothing that can
// reject — no `.catch` is needed. Fire-and-forget.
// reject — no `.catch` is needed (the tracker's settlement bookkeeping
// would absorb one anyway).
const child = ctx.get('agents')?.get(info.id)
void runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {} })
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
})
}

View File

@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { Context, type Fiber } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -39,6 +39,11 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
}
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
return (await harnessWithFiber(configDir, adapter)).ctx
}
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -47,9 +52,9 @@ async function harness(configDir: string, adapter: MockAdapter): Promise<Context
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
return { ctx, hooks }
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
@@ -285,19 +290,51 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
} }))
const adapter = new MockAdapter([])
const ctx = await harness(dir, adapter)
const { ctx, hooks } = await harnessWithFiber(dir, adapter)
// Drive the observe-only lifecycle events directly (no real child needed — the
// bridge just listens). The agents registry is absent here, so SubagentStart's
// bridge just listens). No child agent is registered, so SubagentStart's
// child lookup yields undefined and it simply runs the hook.
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
// Both hooks run async (detached .then); poll for their marker files rather
// than a fixed sleep that flakes under load.
const { existsSync } = await import('node:fs')
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
expect(existsSync(startMarker)).toBe(true)
expect(existsSync(stopMarker)).toBe(true)
// The markers prove the hook PROCESSES ran, not that the detached `.then`
// continuations did (`touch` lands before the process exits). Dispose drains
// them, so the no-context arm of the SubagentStart continuation — covered
// only here — executes before this file's coverage snapshot instead of
// racing it (the arm went uncovered on a loaded CI runner and failed the
// per-file 100% branch gate).
await hooks.dispose()
})
it('disposing the bridge aborts a still-running hook and drains to quiescence', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const marker = join(dir, 'started')
const slowHook = join(dir, 'slow.sh')
// Touch the marker FIRST so the test can tell "the hook is genuinely
// mid-run", then sleep far past the suite timeout: dispose resolving at all
// proves the drain KILLED the process (the tracker's abort signal) instead
// of awaiting its exit or its 10-minute default hook timeout.
writeFileSync(slowHook, `#!/usr/bin/env bash\ntouch "${marker}"\nsleep 30\n`)
chmodSync(slowHook, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
SubagentStart: [{ hooks: [{ type: 'command', command: slowHook }] }],
} }))
const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
await waitFor(() => existsSync(marker))
await hooks.dispose()
// The aborted run resolves as a non-blocking error (runHook never rejects),
// so the drained continuation must NOT have logged a failure.
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})