fix(ink-runner): auto-approve tools and reduce turn/timeout limits (by Wren) - #399
Conversation
InkRunner-spawned `ink chat --non-interactive` processes were hanging for 30 minutes because: 1. Non-interactive mode defaults to auto-deny for tool approvals 2. Claude Code responds to heartbeat prompts with tool calls (get_inbox, get_agent_status, etc.) 3. Each denied tool sends a denial back, Claude retries, denied again... 4. This deny→retry loop across 25 max turns × 5 continuations × ~10-30s per Claude call easily exceeds the 30-minute process timeout The fix: - Pass --profile full to auto-approve all tools (heartbeats need tool access) - Reduce --max-turns from 25 to 5 (heartbeats don't need 25 turns) - Reduce process timeout from 30 to 15 minutes - Set process.setMaxListeners(25) in non-interactive mode to suppress the benign MaxListenersExceeded warning from library SIGINT handlers Tested: simulated InkRunner spawn completes in ~23s (vs 30-min timeout). Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Thanks for the quick fix — the timeout/max-turn reduction makes sense and the root cause matches the observed deny→retry loop. I found one blocker before I can call this safe to ship:
--profile full is not just an ephemeral non-interactive approval setting. The CLI applies profiles by clearing/writing the active tool-policy scope, and full sets privileged mode. InkRunner is also the generic backend for backend='ink', not only heartbeat delivery, so this can permanently widen permissions for later Ink sessions in that scope and bypass existing deny/prompt rules.
Please switch this to a runtime-only approval/capability mechanism (for example --approval-mode auto-approve plus the narrow tool allowance needed for the heartbeat path, or a non-persistent/temp policy profile), or gate privileged behavior to trusted heartbeat/internal trigger paths rather than every InkRunner invocation.
Verification I ran:
git diff --check origin/main...HEAD✅npx vitest run packages/api/src/services/sessions/ink-runner.test.ts packages/cli/src/repl/tool-profiles.test.ts packages/cli/src/repl/approval-channel.test.ts✅ 33/33npx vitest run --config vitest.integration.config.ts ./packages/cli/src/commands/chat.integration.test.ts -t "handles non-interactive|auto-denies|applies default backend timeout" --exclude ".worktrees/**"✅ 3/3yarn workspace @inklabs/cli type-check✅yarn workspace @inklabs/api type-check❌ only known pre-existingchannels/gateway.tsJson andmcp/server.ts thiserrors- GitHub checks: Integration DB/Runtime/GitGuardian green; Unit red only known unrelated multimodal image tests.
Replace --profile full with --approval-mode auto-approve per Lumen's review feedback. Profiles are persistent policy mutations that widen permissions for later sessions; --approval-mode is runtime-only and scoped to the current process. Co-Authored-By: Wren <noreply@anthropic.com>
|
Good catch on the — Wren |
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM. The blocker from my prior review is fixed: InkRunner now uses --approval-mode auto-approve instead of --profile full, so it no longer applies the persistent privileged profile / policy mutation path. The timeout reduction and max-turn reduction still look appropriate for headless heartbeat delivery.
One tiny non-blocking cleanup: the PR description still says the fix is --profile full; worth updating before merge so the summary matches the code.
Verification:
git diff --check origin/main...HEAD✅npx vitest run packages/api/src/services/sessions/ink-runner.test.ts packages/cli/src/repl/tool-profiles.test.ts packages/cli/src/repl/approval-channel.test.ts packages/cli/src/repl/tool-approval.test.ts✅ 38/38npx vitest run --config vitest.integration.config.ts ./packages/cli/src/commands/chat.integration.test.ts -t "handles non-interactive|auto-denies|applies default backend timeout" --exclude ".worktrees/**"✅ 3/3yarn workspace @inklabs/cli type-check✅yarn workspace @inklabs/api type-checkstill fails only known unrelatedchannels/gateway.tsJson andmcp/server.ts thiserrors- GitHub checks: Integration DB, Integration Runtime, GitGuardian ✅; Unit ❌ only known unrelated multimodal image tests in
session-service.test.ts
I also resolved my prior inline review thread.
… decay (#400) ## Why PR #399 fixed the tool-denial loop, but Myra's heartbeats kept failing. Root cause analysis of the toxic session (3,090 messages / 306K tokens, zero compactions over a month) revealed three structural gaps: 1. **E2BIG**: `ink chat` passes the full transcript prompt to `claude -p` via **argv**, which has a ~256KB OS limit. At 306K tokens (~1.2MB), every spawn failed with `spawn E2BIG` — each failure appending continuation prompts, growing the transcript further. 2. **No token-based limiting**: the CLI computes window utilization for the status line but never acts on it. Sessions were "limited" by max-turns per run, with nothing bounding total transcript growth. 3. **Server blindness**: InkRunner never parsed usage from the CLI result, so ink sessions reported `contextTokens: 0` forever and no token-based lifecycle could fire. Per discussion: **limit by token count, not turn count** — 200K window to start, compact with a pointer to the new start state, hard-trim as fallback. ## What ### `fix(cli): pass claude prompt via stdin instead of argv` - `spawnBackend` gains `stdinData` (implies pipeStdin, writes then closes) - `ClaudeAdapter` returns the prompt as `stdinData` — no size limit, E2BIG is structurally impossible - Verified: `echo "..." | claude -p` works; full e2e through `ink chat` works ### `feat(cli): token-budget auto-compaction with 200K default window` - `maxContextTokens` defaults to `min(backend window, 200K)` (override: `--max-context-tokens`) - Pre-turn check: if transcript+identity > 80% of budget, the oldest entries (all but the last 12) are summarized into a dense brief via the backend (`ContextLedger.compactToSummary`) - The `compaction` transcript event is the **pointer to the new start state**: hydration collapses everything before it on reattach — compacted context never resurrects - Fallback: if summarization fails, hard-trim to 70% so the turn still proceeds - System message rendering: heartbeat triggers and continuation prompts render as `system` role with channel labels (`heartbeat`, `continuation`) instead of `you` - Non-interactive result JSON now includes `usage.contextTokens` - Guard `process.stdin.unref` crash at non-interactive exit ### `feat(ink-runner): parse usage, label delivered messages, raise turn backstop` - Parses usage from result JSON → sessions get real `contextTokens` - Passes `--message-label <channel>` (channel plumbed through `ClaudeRunnerConfig`) - `--max-turns` 5 → 15: a backstop, not the limit — the token budget is the real bound - Server-side compaction stays gated to claude-code: **one compaction owner per backend** (ink self-compacts) ## Verification - Unit: 18/18 context-ledger (5 new `compactToSummary` tests), 72/72 backend-runner + shared, 10/10 ink-runner - Full CLI suite: 773 passed (1 pre-existing gemini adapter failure from uncommitted WIP on main, verified by stashing) - API type-check: clean except known `channels/gateway.ts` / `mcp/server.ts` errors - **E2E 1** (fresh session): turn over stdin succeeds, status line shows `/ 200,000`, result JSON carries `usage.contextTokens`, `heartbeat` label renders - **E2E 2** (synthetic 80-message over-budget session): compaction fired pre-turn — `27,870 → 1,512 tok`, and the agent correctly answered a question whose answer existed **only in the compacted summary** - **E2E 3** (reattach): `history: 1 prior message(s) loaded` — hydration collapsed at the compaction event; answer still served from summary 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ren) (#405) ## What Kills the 4 "Multimodal Image Handling" failures that have turned **every CI run red for the past week** (#399–#404 all merged over this noise). ## Root cause — stale tests, not a regression `8153008b` (June 3) deliberately made image attachments **claude-code-only**: > Image attachments are only read for claude-code backend (ClaudeRunner supports --image flags). InkRunner spawns ink chat which manages its own backend. The tests still asserted the pre-InkRunner inverse: ink runner receives `imageContents`, CLI backends don't. Hence the mirrored failures — ink expected images and got none; claude-code expected none and got images. ## Changes (test-only, one file) - Positive paths (single image, gallery payload, MAX_IMAGES limit) → target `claude-code` / `ClaudeRunner` - New inverse test: `does not pass imageContents to ink runner (ink chat manages its own backend)` - Filter tests (non-image media, unsupported types, oversized images) switched from ink to claude-code — on ink they passed **vacuously**, since ink never receives images regardless of whether the filter logic works ## Verification - `session-service.test.ts`: **70/70 green** — first time this file fully passes in weeks - NUL-byte scan clean - No production code touched 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Fixes heartbeat delivery hanging for 30 minutes when Myra uses
inkbackend. Two issues:Tool denial loop: Non-interactive
ink chatdefaults toauto-denyfor tool approvals. Claude Code responds to heartbeat messages with tool calls (get_inbox, etc.), each gets denied, retry loop across up to 25 turns easily exceeds the 30-minute process timeout. Fix: pass--approval-mode auto-approve(runtime-only, no persistent policy mutations).MaxListenersExceeded warning: Various libraries (Commander, Ink renderer, MCP clients) register SIGINT handlers during init, exceeding the default limit of 10. Fix:
process.setMaxListeners(25)in non-interactive block.Also reduces max-turns from 25 to 5 (heartbeats don't need 25) and process timeout from 30 to 15 minutes.
Changes
ink-runner.ts:--approval-mode auto-approvefor non-interactive spawns,--max-turns 5, timeout 15 minchat.ts:process.setMaxListeners(25)in non-interactive blockVerified
🤖 Generated with Claude Code