---
name: stuck-active-idle-blackhole
description: "2026-07-08 (v0.15.9): spt-hosted claude-spt endpoint that never completed a turn stays ACTIVE forever → daemon spools every inbound, never drains (silent black-hole). Fix: idle-assert at bringup + Notification(idle_prompt) self-heal. Also: info.json pids are DEAD on every agent — never a liveness signal."
metadata: 
  node_type: memory
  type: project
  originSessionId: 13712404-60aa-4dc9-957b-9e854ac0f73d
---

Operator /diagnose of live agent `lia` (not receiving messages). Root cause + fix (v0.15.9,
REQ-HAZARD-STUCK-ACTIVE-NO-IDLE).

**Delivery model (spt-hosted / daemon-driven):** the daemon pushes inbound to the translation
binary ONLY when the endpoint activity state is IDLE. While ACTIVE it defers to the spool
(`ENDPOINT_INJECT:<id>: endpoint ACTIVE -> spool, not injected`) and drains on the NEXT idle
transition (`IDLE_PARKED_DRAIN`). So a stuck-ACTIVE endpoint silently black-holes everything:
spool rows `delivered=0`, `taken_*=None`, endpoint still shows ONLINE/CONTROLLED.

**Why lia stuck ACTIVE:** claude-spt marked `state idle` ONLY on Stop (turn end, hook.rs), a
`/clear` boundary, or a checkpoint write — NEVER at `startup`, and with NO idle-at-prompt handler.
lia booted logged-out, operator `/login`'d (a prompt submission → UserPromptSubmit → `state busy`,
but a client-side command = NO agent turn = NO Stop), and never had a real turn → nothing ever
marked her idle → stuck ACTIVE from boot. 14 messages parked, undelivered.

**Proof + recovery:** `spt api --adapter claude-spt state idle lia --session-id <hers>` →
`IDLE_PARKED_DRAIN:lia: injected 14 parked message(s) via translation binary` instantly. **The
translation binary was healthy all along — never fed.** That command is the live-recovery for any
stranded endpoint.

**Fix (two layers):** (1) the spt-hosted BIND path asserts `state idle` at bringup (fresh session =
idle input box = immediately receivable) — rides the adapter BINARY (`spt adapter update`). (2) a new
**Notification hook** (hooks.json `idle_prompt` matcher, $SPT_ENDPOINT_ID-gated) re-asserts idle
whenever CC reports the session idle at its prompt — the self-heal from `/login` or any no-Stop
submission. Layer 2's WIRING rides the **cplugs skeleton** (plugin.json 0.1.10 + hooks.json), so it
needs `claude plugin install sptc@cplugs` + `/reload-plugins` — NOT just the adapter update.

**Follow-up — v0.16.0 receive-heal (2026-07-08, SHIPPED: gh release v0.16.0 + `spt adapter update`
applied on box; cplugs skeleton 0.1.11 pushed — needs `claude plugin install sptc@cplugs` +
/reload-plugins on lia to activate the hook wiring):** two MORE
no-Stop doors to the same black-hole: (a) an **API-error turn-end** (rate_limit/server_error/
max_output_tokens) fires `StopFailure`, not `Stop`; (b) an **operator Esc mid-tool-call** fires
`PostToolUseFailure` with `is_interrupt==true`, and a **pure-thinking Esc** fires NEITHER. Fixes 1-2
(keepers, shipped in 0.16.0): wire StopFailure→idle (REQ-HAZARD-STOPFAILURE-STUCK-BUSY) +
PostToolUseFailure→idle-on-interrupt-only (REQ-HAZARD-INTERRUPT-STUCK-BUSY; non-interrupt failure ≠
turn-end, no mark). Notification matcher widened `idle_prompt`→`*`, handler now TYPE-AWARE (logs every
type, marks idle only on idle_prompt — a permission_prompt must NOT drain the spool mid-turn).
**Field repro corrections (2026-07-08, live on lia):**
- **`!` bash input does NOT fire UserPromptSubmit** → the endpoint never goes busy → a probe landing
  after a `!`-then-Esc is NOT a heal, just never-stuck. Early "pure-thinking Esc healed" was this
  artifact — WRONG. Always repro with a GENUINE prompt submission (real UPS → busy) before the Esc.
- After a REAL turn, an Esc — **mid-tool OR pure-thinking — leaves lia DEAF** (probes QUEUED, never
  drain, even 50s later). NEITHER interrupt path self-heals. `idle_prompt` does NOT fire (or doesn't
  heal) at the "Interrupted · What should Claude do instead?" prompt.
- **hook `eprintln` is UNREADABLE off a broker session**: CC hides hook stderr; the daemon
  (`daemon.stderr.log`) captures only the stderr of adapters IT spawns (psyche/echo-commune), NEVER
  CC-hook stderr. `daemon-effects.log` does NOT persist ENDPOINT_INJECT/spool decisions either. So
  Step-0's eprintln instrument was blind → **v0.16.1** tees every hook log to a rolling
  `{adapter_dir}/hook-trace.log` (adapter dir = `…/spt-core/adapters/_github/SaberMage-claude-spt/`).
- **v0.16.1 also HARDENS the heal** (likely the deaf-lia root): `PostToolUseFailure` payload OMITS
  `session_id` → the 0.16.0 self_id+payload-sid path fired `state idle --session-id ""` → core
  AUTH-refuses silently. Now id←`$SPT_ENDPOINT_ID`, sid←bind carrier `state/session/<id>.sid` fallback;
  never fires empty-sid (`RECEIVE_HEAL_SKIP` logged). SHIPPED (gh v0.16.1 + `spt adapter update`,
  binary-only, no reload).

**Step-0 VERDICT (field-settled 2026-07-08): an Esc-interrupt fires NO usable CC hook.** Of the 27 CC
hook events there is no "turn cancelled / user interrupt" event: `Stop`=clean-finish-only (never on
Esc, else 0.15.9 would've healed it), `StopFailure`=API-error-only, `PostToolUseFailure`=tool-only AND
did NOT fire on lia's tool-interrupt (no trace line), pure-thinking Esc=nothing. So hook fixes 1-2
structurally cannot heal a hookless interrupt → **fix 3 (the digest-pull watcher) is the real fix.**
- The interrupt IS digest-visible: `spt endpoint digest <id> --last 1 --json` → latest turn
  `{"input":"[Request interrupted by user...]","entries":[],"partial":true}`. TWO marker variants:
  `[Request interrupted by user]` (thinking) + `[Request interrupted by user for tool use]` (tool) →
  match the `[Request interrupted by user` PREFIX.
- **Whole chain manually proven on stuck lia:** `spt api --adapter claude-spt state idle lia
  --session-id <carrier-sid>` → `IDLE:lia (gate-armed)` → probe → `SENT` (was QUEUED). carrier sid =
  `{adapter_dir}/state/session/<id>.sid` (adapter dir = `…/adapters/_github/SaberMage-claude-spt/`).

**SHIPPED v0.16.2 (fix 3, Item 1 CLOSED):** `tools/claude-spt/src/interrupt_watch.rs` — a 15s heartbeat
thread hosted by the persistent translation binary (`translate.rs`, spawned from its `init` handshake)
pulls the digest, detects the marker, marks idle via the carrier sid (heal-once, re-arm when the agent
moves on). **ACTIVATION CAVEAT: the watcher rides the PERSISTENT translation binary — `spt adapter
update` swaps the binary but a session's ALREADY-RUNNING translate keeps the old one, so fix 3 activates
only on an endpoint's next bringup/BOUNCE** (fixes 1-2 activate per-hook immediately). Until lia is
bounced, its live translate has no watcher — manual heal (above) still recovers it. Live autonomous-
watcher verify = deferred int (needs a bounce + interactive Esc). `hook-trace.log` node-wide gotcha: ALL
live agents share the one claude-spt binary + trace file, and log lines carry no endpoint id → can't
isolate one agent from it.

**TRAP I fell into (don't repeat):** I first concluded lia's CC process was dead because
`info.json` pid `ps`-checked as gone. WRONG — the recorded `info.json` pids are DEAD ccs-shim pids on
EVERY agent (deployah/doyle/todlando all `ps`-DEAD too). Never use `info.json` pid as a liveness
signal (also noted in [[live-perch-monitor-reachability]]). The activity-state + daemon-log
(`ACTIVE -> spool` vs `→ translation binary`) is the real discriminator. Related:
[[resume-rebind-wedge]] (sibling SessionStart-state hazard), [[live-perch-monitor-reachability]].
