diff --git a/README.md b/README.md index e194bdc..30ff383 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ The five questions and actions in the chart: | Recall | a tool call failed | shows Claude the lesson that describes the failure | | Capture | a call works after one failed | decides whether it is the fix, and if so tells Claude to record the lesson | | Stop check | Claude is about to finish | refuses the stop once if a lesson is owed and not yet recorded or declined | +| In-flight check | Claude is about to finish | refuses the stop once if work is still in flight with nothing watching it: a detached process, a workflow run dispatched by hand, or the user's own pull request with pending checks, and no polling loop alive. Runs `hooks/inflight-stop-check.sh`, which can also be registered directly as a Stop hook | Two things happen beside the chart. A request that keeps coming back is offered a skill, as described above. And each time a session invokes a skill that compound lists, the use diff --git a/hooks/decisions.test.ts b/hooks/decisions.test.ts index 451489b..c7d0a6f 100644 --- a/hooks/decisions.test.ts +++ b/hooks/decisions.test.ts @@ -220,6 +220,9 @@ type World = { // What `check` prints whatever the call, and what `log --json` prints, when the test says. check: string | undefined logReply: string | undefined + // What the in-flight check prints at a stop ('' = nothing in flight), and the stdin it got. + inflight: string + inflightStdin: string[] } let worlds = 0 @@ -230,7 +233,7 @@ let worlds = 0 function world(on: On): World { worlds += 1 const n = worlds - const w: World = { bare: true, calls: [], logged: [], asked: 0, judge: '{"name":null}', show: '', verdict: { counted: true, ineffective: false }, fails: () => false, owed: [], check: undefined, logReply: undefined } + const w: World = { bare: true, calls: [], logged: [], asked: 0, judge: '{"name":null}', show: '', verdict: { counted: true, ineffective: false }, fails: () => false, owed: [], check: undefined, logReply: undefined, inflight: '', inflightStdin: [] } mock.env(on, { HOME: '/home/me', COMPOUND_HOME: '/home/me/compound', COMPOUND_PROMPT_MIN_CHARS: '100000' }) on('session.id', () => ({ value: `decisions-${n}` })) on('session.root', () => ({ value: `/work/decisions-${n}` })) @@ -255,6 +258,10 @@ function world(on: On): World { w.logged.push(sent) return done(argv.includes('--json') ? (w.logReply ?? JSON.stringify({ ts: '2026-10-05T12:00:00Z', session: `decisions-${n}`, ...event })) : '') } + if (String(argv[1] ?? '').endsWith('/hooks/inflight-stop-check.sh')) { + w.inflightStdin.push(e.init?.stdin ?? '') + return done(w.inflight) + } if (verb === 'list') return done(LESSONS) if (verb === 'show') return done(w.show) if (verb === 'check') { @@ -543,3 +550,45 @@ test('the stop refused for two owed lessons carries both ids, as the CLI listed expect(stop.block).toContain('- 1: --settles ab12cd34') expect(stop.block).toContain('- 2: --settles ef56ab78') }) + +// ---- the in-flight check at the stop ------------------------------------------------------ + +const UNWATCHED = JSON.stringify({ decision: 'block', reason: 'Work is still in flight and NO waiter is running:\nDETACHED PROCESSES:\npid 6263 (up 17:12) python canary-runner.py' }) + +test('a stop with work in flight and no waiter is refused with what is in flight', async ($, on) => { + const w = world(on) + mock.clock(on, { now: 1_800_000_000_000 }) + w.inflight = UNWATCHED + const stop = await $.classic.Stop({ stop_hook_active: false } as never) + expect(stop.block).toContain('[compound] Work is still in flight and NO waiter is running') + expect(stop.block).toContain('pid 6263') + expect(w.inflightStdin).toEqual([JSON.stringify({ stop_hook_active: false })]) +}) + +test('the check is told when the stop follows a block, so the script can stay silent', async ($, on) => { + const w = world(on) + mock.clock(on, { now: 1_800_000_000_000 }) + await $.classic.Stop({ stop_hook_active: true } as never) + expect(w.inflightStdin).toEqual([JSON.stringify({ stop_hook_active: true })]) +}) + +test('nothing in flight, or a check that prints something unreadable, lets the stop through', async ($, on) => { + const w = world(on) + mock.clock(on, { now: 1_800_000_000_000 }) + expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined() + w.inflight = 'not json at all' + expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined() + w.inflight = JSON.stringify({ decision: 'approve', reason: 'fine' }) + expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined() +}) + +test('an owed lesson and unwatched work are both in the one refusal', async ($, on) => { + const w = world(on) + mock.clock(on, { now: 1_800_000_000_000 }) + w.owed = [{ ts: '2026-10-05T12:00:00Z', type: 'capture', id: 'ab12cd34', call: 'c1', tool: 'Bash', failed: './deploy.sh', error: 'no target', fixed: './deploy.sh --target x' }] + w.inflight = UNWATCHED + const stop = await $.classic.Stop({ stop_hook_active: false } as never) + expect(stop.block).toContain('This session owes a lesson') + expect(stop.block).toContain('./deploy.sh --target x') + expect(stop.block).toContain('NO waiter is running') +}) diff --git a/hooks/inflight-stop-check.sh b/hooks/inflight-stop-check.sh new file mode 100755 index 0000000..8f9a7d9 --- /dev/null +++ b/hooks/inflight-stop-check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Stop hook: refuse to end a turn that leaves work in flight with nothing watching it. +# Blocks at most once per stop (stop_hook_active), and only for work that is plausibly +# this user's own: detached processes, runs they dispatched, their PRs with pending checks. +input=$(cat) +[ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ] && exit 0 + +items="" +# 1. Detached processes (reparented to init) that look like a test suite or a Claude-launched job. +procs=$(ps -eo ppid=,pid=,etime=,command= 2>/dev/null \ + | awk '$1==1' | grep -E 'pytest|/claude-[0-9]+/|nohup' | grep -vE 'grep|inflight-stop-check' \ + | awk '{printf "pid %s (up %s) %s\n", $2, $3, substr($0, index($0,$4), 110)}' | head -6) +[ -n "$procs" ] && items="${items}DETACHED PROCESSES:\n${procs}\n" + +if command -v gh >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then + # 2. Workflow runs I dispatched by hand that are still going (cron runs are not mine to watch). + me=$(gh api user --jq .login 2>/dev/null) # "@me" is not accepted by `gh run list --user` + runs=$(gh run list --user "$me" --event workflow_dispatch --status in_progress --limit 6 \ + --json databaseId,workflowName --jq '.[]|"run \(.databaseId) \(.workflowName)"' 2>/dev/null) + [ -n "$runs" ] && items="${items}DISPATCHED RUNS IN PROGRESS:\n${runs}\n" + # 3. My open PRs with checks still pending. + for n in $(gh pr list --author @me --state open --limit 10 --json number --jq '.[].number' 2>/dev/null); do + p=$(gh pr checks "$n" --json state --jq '[.[]|select(.state=="IN_PROGRESS" or .state=="QUEUED" or .state=="PENDING")]|length' 2>/dev/null) + [ "${p:-0}" != "0" ] && items="${items}PR #$n: $p check(s) pending\n" + done +fi +[ -z "$items" ] && exit 0 + +# A waiter is a live polling loop: a shell whose command sleeps AND polls gh / a pid / a log. +waiters=$(ps -eo command= 2>/dev/null | grep -E 'sleep [0-9]+' \ + | grep -E 'gh (run|pr) |ps -p|pgrep|grep -q' | grep -vE 'inflight-stop-check' | grep -vc '^grep') +[ "${waiters:-0}" -gt 0 ] && exit 0 + +reason=$(printf 'Work is still in flight and NO waiter is running:\n%b\nBefore stopping, either start a background waiter (run_in_background) that exits when these finish, with a deadline, or say plainly in the final message that nobody is watching them and when to look.' "$items") +jq -n --arg r "$reason" '{decision:"block", reason:$r}' diff --git a/hooks/register.ts b/hooks/register.ts index 3821da8..36c3ed8 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -1423,6 +1423,46 @@ function turnEnded(sid: string): undefined { return undefined } +// ---- the in-flight check at the stop ------------------------------------------------------ + +// hooks/inflight-stop-check.sh is the same script a person can register directly as a Stop +// hook. It answers one question: is work still in flight (a detached process, a workflow +// run dispatched by hand, the user's own pull request with pending checks) with no polling +// loop alive to see it finish? When it is, the script prints a Stop-hook JSON decision and +// its reason is the refusal. The script itself refuses only once per stop: it is told +// whether this stop follows a block and stays silent when it does. +// +// The failure it prevents: a turn starts a three-hour run, dispatches a workflow, opens +// pull requests, writes a status summary and ends. Nothing fails; the work finishes or +// stalls unseen. A lesson that says "keep a waiter going" is not enough, because the +// session that forgets the waiter forgets the lesson too. +// +// It never throws and never blocks on its own failure: a script that is missing, times +// out or prints something unreadable lets the stop through. +const INFLIGHT_SH = 'hooks/inflight-stop-check.sh' +const INFLIGHT_BUDGET_MS = 45000 +const BASH = '/bin/bash' + +async function inflight($: EngineInterface, followsBlock: boolean): Promise { + if (await off($)) return undefined + const script = `${$.plugin.root}/${INFLIGHT_SH}` + try { + if (!(await $.fs.exists(script))) return undefined + const done = await $.process.run([BASH, script], { + cwd: await projectRoot($), + timeoutMs: INFLIGHT_BUDGET_MS, + stdin: JSON.stringify({ stop_hook_active: followsBlock }), + }) + const printed = done.stdout.trim() + if (printed === '') return undefined + const said = JSON.parse(printed) as { decision?: unknown, reason?: unknown } + if (said.decision !== 'block' || typeof said.reason !== 'string' || said.reason === '') return undefined + return `[compound] ${said.reason}` + } catch { + return undefined + } +} + async function onStop($: EngineInterface, followsBlock: boolean): Promise { if (await off($)) return undefined const sid = await $.session.id() @@ -1743,7 +1783,10 @@ export const register: Register = on => { on('classic.Stop', async ($, e, next) => { const result = await next(e) try { - const block = await onStop($, e.stop_hook_active === true) + const followsBlock = e.stop_hook_active === true + const owed = await onStop($, followsBlock) + const unwatched = await inflight($, followsBlock) + const block = owed === undefined ? unwatched : (unwatched === undefined ? owed : `${owed}\n\n${unwatched}`) if (block === undefined) return result return { ...result, block: result.block === undefined ? block : `${result.block}\n\n${block}` } } catch (err) {