Skip to content

PR3 v0.3.0 - real intervention + stuck-timeout + intervention cap - #3

Open
bettercallzaal wants to merge 1 commit into
feat/pr2-supervisorfrom
feat/pr3-intervention
Open

PR3 v0.3.0 - real intervention + stuck-timeout + intervention cap#3
bettercallzaal wants to merge 1 commit into
feat/pr2-supervisorfrom
feat/pr3-intervention

Conversation

@bettercallzaal

Copy link
Copy Markdown
Owner

Stacked on PR #2. Rebase target switches to main after PR2 merges.

What this PR teaches

PR2 watched the stream and logged supervisor verdicts. PR3 wires them through. The first PR where, in production, a stuck or off-track Hermes session gets pulled back on course mid-run instead of after-the-fact.

The hard part wasn't the supervisor logic - it was the runner. claude --print is one-shot; you cannot inject mid-run input. The fix: capture the session_id from the first stream-json system line, then on intervene() kill the live process and respawn claude --resume <id> ... <message> with the same systemPrompt + tool whitelist. The orchestrator's stream loop transparently continues - the new process appends to the same output buffer.

Mechanics

Mechanism Where Default
session_id capture wireProcess() in HermesRunner, regex on stdout first system line
process swap on intervene intervene() kills old, spawns claude --resume ... uses cached spawnConfig
stuck-timeout race orchestrator drain loop, Promise.race(iter.next(), sleep) stuckTimeoutMs: 60_000
intervention cap orchestrator maxInterventions: 3
escalation past cap orchestrator next intervene/stuck → kill + aborted
supervisor double-fire fix state.recentAssistantMessages = [] after loop fire resets the window

The swapping flag on ActiveRun is the load-bearing detail. Without it, the SIGTERM on the old process triggers close which sets closed = true, the stream loop exits, and the orchestrator never sees the resumed events. With it, the close handler skips finalisation during the swap window.

Limits left for later PRs

  • PR4 - learning loop becomes live - supervisor still uses static loopThreshold per call. PR4 wires learner.retrieve against past intervened outcomes for this pattern; the supervisor becomes adaptive (e.g. drop loopThreshold to 2 if this pattern historically loops at 3). Dormant until Bonfire admin labeling unlocks - the code path lands now.
  • PR5 - more PatternAdapters - research-doc, meeting-capture. Today only hermes-bug-fix exists.
  • PR6 - ChannelAdapter for Telegram - the dual-surface concierge integration.

Verification

typecheck   clean
tests       26 passed / 0 failed  (autonomy 7, router 3, supervisor 8, orchestrator 8)
build       clean dist/

New orchestrator tests:

  • calls runner.intervene on loop-detected verdict with acted=true
  • stuck-timeout fires when the stream goes quiet
  • maxInterventions cap escalates a verdict-driven intervene to kill

Build-in-public note

This is the PR I would have wanted three weeks ago when a research subagent burnt 40 minutes spinning on WebFetch against a Reddit URL that always returned 403. With PR3 in place: supervisor flags off-track on tool call #2, orchestrator nudges with "use /fetch skill instead - reddit blocks WebFetch", claude --resume picks up the redirect, agent recovers. Without it: 40 minutes of stuck-loop billed silently.

Lands on merge

  • Auto-tag v0.3.0 after PR2 → PR3 cascade
  • The acted: true flag is now real - downstream consumers can trust it

🤖 Generated with Claude Code

PR2 logged supervisor verdicts but did not act. PR3 wires them through.

What lands:
- HermesRunner captures session_id from the first stream-json system line,
  then on intervene() kills the live claude process and respawns
  `claude --resume <session_id> --print --output-format stream-json ... <message>`
  preserving the same systemPrompt + tool whitelist. The orchestrator's
  stream loop transparently continues - the new process appends to the
  same output buffer.
- Orchestrator races iter.next() against a stuck-timeout (default 60s,
  opt-out with stuckTimeoutMs: 0). On stuck, records an intervened event
  and calls runner.intervene with a "are you stuck?" nudge.
- maxInterventions cap (default 3). After N interventions, the next
  intervene-or-stuck escalates to kill + aborted outcome. Prevents
  unbounded back-and-forth on a hopelessly off-track session.
- All intervened events now record acted: true (was false in PR2).
- supervisor.ts clears its loop window after firing a loop verdict so
  the orchestrator does not double-fire on the next identical message.

Surface area:
- types.ts: OrchestrateOptions += stuckTimeoutMs, maxInterventions
- adapters/hermes-runner.ts: session_id capture, intervene() implementation,
  swapping flag so the close handler does not finalize the run mid-swap
- orchestrator.ts: stuck-timeout race in drain loop, intervene call,
  intervention cap with kill escalation
- supervisor.ts: reset state.recentAssistantMessages after fire
- tests/orchestrator.test.ts: +3 PR3 tests
  - calls runner.intervene on loop verdict with acted=true
  - stuck-timeout fires and caps escalate to kill
  - max-interventions cap escalates verdict-driven intervene to kill

Tests: 26/26 pass (+3 orchestrator).

Known limit (PR4): supervisor still uses static loopThreshold per call.
Learning from past intervened outcomes lives in PR4 when learner.retrieve
returns hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant