From 88b057cd5e143de924f24d31af60abb0dec76dce Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Sat, 8 Aug 2026 17:51:28 +0530 Subject: [PATCH] fix(sessions): an interrupted turn stops reading "working" (v0.325.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code fires NOTHING on a user interrupt (Esc / Ctrl-C): no Stop, no StopFailure, no SessionEnd. A UserInterrupt event has been requested (anthropics/claude-code#9516) and does not exist. So the turn never ended server-side and the session spun until the 2h wedged-turn ceiling. The signal we DO get is the Notification hook: the TUI parks at its prompt — including "Interrupted · What should Claude do instead?" — and claude raises idle_prompt (159 of them on the live instapods box). notify() now ends the turn as well as ringing the bell, for all three human-blocked kinds (idle_prompt, permission_prompt, agent_needs_input): being blocked on a human is by definition not generating. The session then reads `needs you`, which is the honest state — claude is literally asking what to do instead. The other half of the loop: markTurnBusy (a submitted prompt) retires the open waiting card. Without it an interrupted session would keep reading `needs you` — which outranks `working` — through the entire next turn. An interrupted UNATTENDED run is deliberately left alive rather than reaped: a human stopped it on purpose and now owns it. 8 new assertions in scripts/turn-lifecycle-test.cjs (40 total); the interrupt gap is documented in docs/session-lifecycle-hooks.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz --- CHANGELOG.md | 16 ++++++++++++++++ docs/session-lifecycle-hooks.md | 19 +++++++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- scripts/turn-lifecycle-test.cjs | 34 ++++++++++++++++++++++++++++++++- src/terminal.ts | 12 ++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae9dc1..a48ac7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ new version heading in the same commit. ## [Unreleased] +## [0.325.0] — 2026-08-08 +### Fixed +- **An INTERRUPTED turn (Esc / Ctrl-C) kept reading "working".** Claude Code fires **nothing** on a user + interrupt — no `Stop`, no `StopFailure`, no `SessionEnd`; a `UserInterrupt` event has been requested + ([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)) and does not + exist. So the turn never ended server-side and the session spun until the 2h wedged-turn ceiling. + The signal we *do* get is the `Notification` hook: the TUI parks at its prompt — including + `Interrupted · What should Claude do instead?` — and claude raises `idle_prompt` (159 of them on the + live instapods box). `notify()` now **ends the turn** as well as ringing the bell, for all three + human-blocked kinds (`idle_prompt`, `permission_prompt`, `agent_needs_input`) — being blocked on a + human is by definition not generating. The session reads **`needs you`**, which is the honest state. +- **The other half of that loop:** `markTurnBusy` (a submitted prompt) now retires the open waiting card. + Without it a session that had been interrupted would keep reading `needs you` — which outranks + `working` — through the entire next turn. + + ## [0.324.0] — 2026-08-08 ### Fixed - **Sessions that had finished showed the "working" spinner.** `term_sessions.busy_since` — the flag the diff --git a/docs/session-lifecycle-hooks.md b/docs/session-lifecycle-hooks.md index 69ef5f9..479594b 100644 --- a/docs/session-lifecycle-hooks.md +++ b/docs/session-lifecycle-hooks.md @@ -11,7 +11,7 @@ Reference for the events themselves: . | Hook event | Script | Route | What it does | |---|---|---|---| | `PreToolUse` | `terminal/gate-hook.sh` | `/api/gate` | **The invariant** — every governed effect passes the gateway. Unrelated to status; listed so the table is the whole picture. | -| `Notification` | `terminal/notify-hook.sh` | `/api/notify` | `permission_prompt` / `idle_prompt` / `agent_needs_input` → an inbox card + the per-session "needs you" bell. Other `notification_type`s (auth, elicitation, `agent_completed`) are dropped. | +| `Notification` | `terminal/notify-hook.sh` | `/api/notify` | `permission_prompt` / `idle_prompt` / `agent_needs_input` → an inbox card + the per-session "needs you" bell, **and a turn-END** (blocked on a human is not generating). Other `notification_type`s (auth, elicitation, `agent_completed`) are dropped. | | `UserPromptSubmit` | `terminal/lifecycle-hook.sh` | `/api/session-event` | **Turn START** → `markTurnBusy` (stamps `busy_since`). | | `Stop` | `terminal/stop-hook.sh` | `/api/turn-idle` | **Turn END** → `markTurnIdle`: clears `busy_since` for every lane, and for an *unattended* run tears the pane down (the pile-up guard releases). | | `StopFailure` | `terminal/lifecycle-hook.sh` | `/api/session-event` | **Turn END, errored** (`rate_limit`, `overloaded`, `server_error`, …) → same as `Stop`, plus a `session.turn.failed` audit carrying `error_type`. | @@ -27,6 +27,23 @@ server-side: the run keeps reading "working", the automations pile-up guard keep unattended run parks as a zombie until a 24-hour reaper finds it — the shape behind the recurring "weekly-limit zombie sessions" incidents. +### There is no interrupt hook — the bell stands in for one + +When a human hits **Esc** (or Ctrl-C) mid-turn, Claude Code fires **nothing**: no `Stop`, no +`StopFailure`, no `SessionEnd`. A `UserInterrupt` event has been requested +([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)) and does not exist. +So an interrupted turn used to sit on the console reading "working" until the 2h ceiling. + +The signal we *do* get is the `Notification` hook: the TUI parks at its prompt — including the +`Interrupted · What should Claude do instead?` prompt — and claude raises `idle_prompt` (159 of them on +the live instapods box). `notify()` therefore **ends the turn** as well as posting the bell, and the +session reads `needs you`, which is the honest state: claude is waiting for you to say what to do +instead. Typing the next prompt fires `UserPromptSubmit`, which retires the waiting card and puts the +spinner back — the other half of the loop. + +Note this makes the teardown deliberate: an interrupted **unattended** run is left alive rather than +reaped, because a human stopped it on purpose and now owns it. + ### Why `SessionEnd`'s reason must be read, not assumed `clear`, `resume` and `compact` are **mid-run** events. Treating any `SessionEnd` as terminal would mark a diff --git a/package-lock.json b/package-lock.json index d532db7..4f00390 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.324.0", + "version": "0.325.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.324.0", + "version": "0.325.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 4197c90..d3b06de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.324.0", + "version": "0.325.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/turn-lifecycle-test.cjs b/scripts/turn-lifecycle-test.cjs index 04597e7..cb0169e 100644 --- a/scripts/turn-lifecycle-test.cjs +++ b/scripts/turn-lifecycle-test.cjs @@ -169,7 +169,39 @@ console.log('\n\x1b[1m6) every terminal transition drops the flag\x1b[0m'); assert(row(id).busy_since === null, 'markEnded clears it'); } -console.log('\n\x1b[1m7) an unknown event is ignored, not a crash\x1b[0m'); +console.log('\n\x1b[1m7) an INTERRUPTED turn (Esc) stops reading "working"\x1b[0m'); +console.log(' (Esc/Ctrl-C fires NO Stop, StopFailure or SessionEnd — there is no interrupt hook at all,'); +console.log(' anthropics/claude-code#9516 — so the Notification bell is the only signal we get)'); +{ + const id = mk(); + assert(seen(id).working === true, 'mid-turn → working'); + tm.notify(id, 'agent-demo', 'idle_prompt', 'Interrupted · What should Claude do instead?'); + assert(row(id).busy_since === null, 'the idle notification ends the turn — blocked on a human is not generating'); + assert(seen(id).working === false, 'no spinner'); + const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c; + assert(card === 1, 'and it reads `needs you` — an open waiting card, which outranks working'); +} +{ + // …and typing the next prompt closes that loop: the card is stale the moment the human answers. + const id = mk(); + tm.notify(id, 'agent-demo', 'idle_prompt', 'waiting'); + tm.recordLifecycle(id, 'UserPromptSubmit'); + const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c; + assert(card === 0, 'a submitted prompt retires the waiting card'); + assert(seen(id).working === true, 'back to working, not stuck on `needs you`'); +} +{ + const id = mk(); + tm.notify(id, 'agent-demo', 'permission_prompt', 'Claude needs permission'); + assert(row(id).busy_since === null, 'a permission prompt ends the turn too'); +} +{ + const id = mk(); + tm.notify(id, 'agent-demo', 'auth_success', 'logged in'); // a noise kind + assert(row(id).busy_since != null, 'a NOISE notification kind changes nothing'); +} + +console.log('\n\x1b[1m8) an unknown event is ignored, not a crash\x1b[0m'); { const id = mk(); const before = row(id).busy_since; diff --git a/src/terminal.ts b/src/terminal.ts index 3cc3194..c4ee932 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -3036,6 +3036,11 @@ export class TerminalManager { const now = Date.now(); this.db.prepare('UPDATE term_sessions SET busy_since = ?, last_activity = ?, updated_at = ? WHERE id = ? AND busy_since IS NULL') .run(now, now, now, sessionId); + // A prompt submitted IS the human answering — retire any open "waiting on you" card, or the session + // would keep reading `needs you` (which outranks `working`) through the whole turn it just started. + // This is the close of the loop the interrupt case opens: interrupt → idle_prompt card → you type → + // card gone, spinner back. + this.clearNotifications(sessionId); } /** A turn ENDED — drop `busy_since`. The one place that clears it, so every end path (Stop hook, @@ -4835,6 +4840,13 @@ export class TerminalManager { // newer `agent_needs_input` Claude Code emits when it's blocked on the human. Auth/elicitation noise // and the per-turn `agent_completed` are dropped here (session completion is signalled by markEnded). if (kind !== 'permission_prompt' && kind !== 'idle_prompt' && kind !== 'agent_needs_input') return; + // Blocked on a human is, by definition, NOT generating — so this is a turn-END signal as much as it + // is a bell, and it's the only one we get for a turn the user INTERRUPTED (Esc / Ctrl-C fires no + // `Stop`, no `StopFailure`, no `SessionEnd` — there is no interrupt hook at all, see + // anthropics/claude-code#9516). Without this clear, an interrupted session sat on the console reading + // "working" until the 2h wedged-turn ceiling. `idle_prompt` is what claude raises once the TUI has + // been sitting at its prompt — including the "Interrupted · What should Claude do instead?" prompt. + this.clearTurnBusy(sessionId); this.clearNotifications(sessionId); const fallback = kind === 'permission_prompt' ? 'Claude needs permission to continue.' : 'Claude is waiting for your input.'; const body = (message || '').trim() || fallback;