Merge pull request #193 from deepseek-harness/worktree-hooks-dispose-quiescence

fix(hooks): drain detached hook runs on bridge dispose
This commit is contained in:
Tianyi Cui
2026-07-07 09:15:31 +08:00
committed by GitHub
11 changed files with 276 additions and 19 deletions

View File

@@ -4,7 +4,7 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau
| Package | Role | Shape |
|---|---|---|
| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events, detached-run quiescence | library (no plugin) |
| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |

View File

@@ -13,6 +13,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
| Detached-run quiescence | `createDetachedRuns()` — track fire-and-forget run chains; `drain()` aborts, then awaits them | passes `signal` to each detached `runHook`, registers `drain` as its effect disposer |
## Primitives
@@ -20,6 +21,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
## `hook/*` session events

View File

@@ -0,0 +1,71 @@
/**
* Quiescence tracking for a bridge's DETACHED hook runs. The waterfall-shaped
* hook points (`UserPromptSubmit`, `PreToolUse`, …) are awaited by their seams,
* but the emit-shaped points (`SessionStart`, `SubagentStart`, `SubagentStop`)
* run fire-and-forget: no seam awaits them, so without tracking a bridge's
* disposal could strand a live hook process and let a late continuation fire
* into a disposed context (docs/defensive-patterns.md: dispose must reach
* quiescence). A bridge creates one tracker in `apply()`, passes
* {@link DetachedRuns.signal} to each detached {@link runHook} call, wraps the
* full run chain (the hook run PLUS its `.then` continuation) in
* {@link DetachedRuns.track}, and registers {@link DetachedRuns.drain} as its
* disposer.
*
* @module @deepseek-ai/dsh-hook-protocol/detached
*/
/** In-flight registry for one bridge's detached hook runs; see the module doc for the wiring contract. */
export interface DetachedRuns {
/**
* The abort signal every tracked run must hand to {@link runHook} (via its
* `signal` option). {@link drain} fires it so a still-running hook process is
* killed rather than awaited out to its timeout (default 10 minutes).
*/
readonly signal: AbortSignal
/**
* Register one detached run until it settles. Pass the FULL chain — the hook
* run and its continuation/error handler — so {@link drain} waits for the
* side effects (an inject, a warn), not just the process exit. A rejected
* chain is absorbed here (settlement bookkeeping only), but rejection
* handling is still the caller's job: an untracked `.catch` is what turns a
* failure into a logged warning instead of silence.
* @param run - the detached run chain to hold until settled.
*/
track(run: Promise<unknown>): void
/**
* Abort {@link signal}, then resolve once every tracked chain has settled —
* including chains tracked while the drain is in progress. The bridge
* registers this as its effect disposer; cordis awaits it, so
* `fiber.dispose()` resolving means the bridge's detached work is quiescent.
* A run tracked AFTER drain resolves is not awaited by anyone — by then the
* bridge's listeners are disposed, so nothing can start one.
* @returns resolves when all tracked runs have settled.
*/
drain(): Promise<void>
}
/**
* Create a {@link DetachedRuns} tracker (one per bridge `apply()`); settled
* runs are pruned so a long-lived session does not accumulate them.
* @returns the tracker.
*/
export function createDetachedRuns(): DetachedRuns {
const inflight = new Set<Promise<unknown>>()
const controller = new AbortController()
return {
signal: controller.signal,
track(run: Promise<unknown>): void {
inflight.add(run)
const settled = (): void => { inflight.delete(run) }
void run.then(settled, settled)
},
async drain(): Promise<void> {
controller.abort(new Error('hook bridge disposed'))
// Re-check after each wave: a chain can be tracked while a prior wave is
// settling; loop until the registry is observed empty.
while (inflight.size > 0) {
await Promise.allSettled([...inflight])
}
},
}
}

View File

@@ -15,6 +15,8 @@
* session-event helpers (declaration-merged into `SessionEventMap`);
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
* {@link HookOutput} so the shared event's semantics live in one place.
* - {@link createDetachedRuns} — quiescence tracking for the fire-and-forget
* hook points: disposal aborts and drains a bridge's detached runs.
*
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
@@ -38,3 +40,5 @@ export { mergeHookOutputs } from './merge.ts'
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
export type { HookInvocation, HookResultRecord } from './events.ts'
export { createDetachedRuns } from './detached.ts'
export type { DetachedRuns } from './detached.ts'

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { createDetachedRuns } from '@deepseek-ai/dsh-hook-protocol'
/** A promise settled from outside, so a test controls exactly when a tracked run finishes. */
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
describe('createDetachedRuns', () => {
it('starts with an unfired signal; drain fires it (so still-running hook processes get killed)', async () => {
const detached = createDetachedRuns()
expect(detached.signal.aborted).toBe(false)
await detached.drain()
expect(detached.signal.aborted).toBe(true)
expect(String(detached.signal.reason)).toContain('hook bridge disposed')
})
it('drain with nothing tracked resolves immediately', async () => {
await expect(createDetachedRuns().drain()).resolves.toBeUndefined()
})
it('drain waits for a tracked run to settle', async () => {
const detached = createDetachedRuns()
const run = deferred()
detached.track(run.promise)
let drained = false
const draining = detached.drain().then(() => { drained = true })
// Give the drain every chance to (wrongly) resolve before the run settles.
await new Promise(resolve => setTimeout(resolve, 10))
expect(drained).toBe(false)
run.resolve()
await draining
expect(drained).toBe(true)
})
it('drain waits for a run tracked WHILE a prior wave was settling', async () => {
const detached = createDetachedRuns()
const first = deferred()
const second = deferred()
detached.track(first.promise)
// The late run enters the registry from the first run's own continuation —
// after drain() snapshotted its first wave.
void first.promise.then(() => { detached.track(second.promise) })
let drained = false
const draining = detached.drain().then(() => { drained = true })
first.resolve()
await new Promise(resolve => setTimeout(resolve, 10))
expect(drained).toBe(false)
second.resolve()
await draining
expect(drained).toBe(true)
})
it('a rejected tracked run is absorbed by the settlement bookkeeping (drain still resolves)', async () => {
const detached = createDetachedRuns()
const run = deferred()
detached.track(run.promise)
// The caller-side handler every bridge attaches; the tracker's own
// bookkeeping must not depend on it, but an UNHANDLED rejection would fail
// the test run, which is exactly the guarantee under test.
run.promise.catch(() => {})
run.reject(new Error('hook run boom'))
await expect(detached.drain()).resolves.toBeUndefined()
})
})

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, readFileSync, 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,59 @@ 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 pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
const slowHook = join(dir, 'slow.sh')
// Record the hook shell's PID and touch the marker FIRST so the test can
// tell "the hook is genuinely mid-run", then sleep far past the suite
// timeout. Dispose must KILL the process (the tracker's abort signal), not
// await its exit or its 10-minute default hook timeout.
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\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))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await hooks.dispose()
// Quiescence, not just promptness: the drain resolves only after the run
// settled, and the run settles only after the killed process was reaped —
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
// throws ESRCH). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
expect(() => process.kill(pid, 0)).toThrow()
// 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'))
})
})

View File

@@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
`SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
## Context source
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).

View File

@@ -24,6 +24,7 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
import {
appendHookInvoked,
appendHookResult,
createDetachedRuns,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
@@ -97,6 +98,12 @@ export function apply(ctx: Context, config: Config): void {
const model = config.model ?? ''
// SessionStart is the one emit-shaped (detached) point Codex has: track its
// run chains so disposal aborts a still-running hook process and drains the
// continuation (docs/defensive-patterns.md: dispose must reach quiescence).
const detached = createDetachedRuns()
ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs')
async function runPoint(
point: string,
matchQuery: string,
@@ -189,12 +196,12 @@ export function apply(ctx: Context, config: Config): void {
// the model (a slow hook can miss the first request). Gating is a deferred
// loop-level change; the contract is "injected as soon as the hook resolves".
ctx.on('agent/session-start', (agent, source) => {
void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true })
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, 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-codex: SessionStart hook failed: ${String(error)}`) })
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).

View File

@@ -1,5 +1,5 @@
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, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
@@ -62,6 +62,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
/** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-codex bridge', () => {
it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
const dir = configDir()
@@ -159,6 +168,43 @@ describe('hooks-codex bridge', () => {
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
})
it('disposing the bridge aborts a still-running SessionStart hook and drains to quiescence', async () => {
const dir = configDir()
const pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
// Record the hook shell's PID and touch the marker FIRST so the test can
// tell "the hook is genuinely mid-run", then sleep far past the suite
// timeout. Dispose must KILL the process (the tracker's abort signal wired
// through this bridge's runPoint), not await its exit.
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // fires agent/session-start
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await fiber.dispose()
// Quiescence, not just promptness: the drain resolves only after the run
// settled, and the run settles only after the killed process was reaped —
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
// throws ESRCH). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
expect(() => process.kill(pid, 0)).toThrow()
// 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('SessionStart hook failed'))
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
expect('default' in HooksCodex).toBe(false)
expect(HooksCodex.name).toBe('hooks-codex')