diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 2bdb65d5bf..b483964c32 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -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 | diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index b621e76fe5..8296821503 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 diff --git a/packages/hooks/hook-protocol/src/detached.ts b/packages/hooks/hook-protocol/src/detached.ts new file mode 100644 index 0000000000..878ba7a27a --- /dev/null +++ b/packages/hooks/hook-protocol/src/detached.ts @@ -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): 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 +} + +/** + * 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>() + const controller = new AbortController() + return { + signal: controller.signal, + track(run: Promise): void { + inflight.add(run) + const settled = (): void => { inflight.delete(run) } + void run.then(settled, settled) + }, + async drain(): Promise { + 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]) + } + }, + } +} diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index fc9f658e6f..df8908490d 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -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' diff --git a/packages/hooks/hook-protocol/tests/detached.spec.ts b/packages/hooks/hook-protocol/tests/detached.spec.ts new file mode 100644 index 0000000000..03e10c7a0d --- /dev/null +++ b/packages/hooks/hook-protocol/tests/detached.spec.ts @@ -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; resolve: () => void; reject: (error: Error) => void } { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((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() + }) +}) diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 554fb4b849..f7afe835d2 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -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 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 4ca9ee001e..d16e26e4a6 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -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 })) }) } diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 3e36231e66..2cbc995ce3 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -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 = {}): stri } async function harness(configDir: string, adapter: MockAdapter): Promise { + 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 { @@ -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')) }) }) diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index fa060b46af..736e8a5100 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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' }`). diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 8c15ba5a87..e164d98a70 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -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). diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 0148da104e..e0677306b0 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -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 { } 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 { + 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')