fix(acpx): make stalled ACP session establishment observable (PEN-1995) - #1396
Conversation
`adapterConfig.timeoutSec` was reported as inert on `claude_local`: runs were observed at 65.9x their configured 900s bound, and two exited 0 at 3.4x and 172.7x. The bound is not inert -- it measures the wrong interval. `acpx-engine/execute.ts` arms its AbortController/setTimeout only after the session handle exists, so the timer covers the turn. Session establishment (`runtime.ensureSession` -> agent spawn + `session/new`|`session/load`) is awaited before that, unbounded. On the acpx side, `AcpRuntimeOptions.timeoutMs` reaches only the set-model RPC, so neither `client.start()` nor the handshake is covered either. That gap is where the stall time lives: 90.56min, 16.5h+, and 43.20h have all been observed between the pre-exec log lines and `acpx.session`. It also explains the exit-0-past-bound runs -- once the handshake finally returned, the turn itself completed in ~10s, well inside 900s, so the turn timer had nothing to fire on. No PID is recorded on this path either, since acpx spawns its own client and paperclip's `onSpawn` never runs, which is why the process-lost reaper had nothing to reap. This change is observability only. `ensureSession` is wrapped so it emits `acpx.session_establish` events (`started` / periodic `waiting` / `established` / `failed`) carrying stage, attempt, resume flag, and elapsed time. That keeps the run's last-output timestamp advancing, so a stalled handshake is visible while it happens rather than only in hindsight, and it yields the recoverability distribution needed before any kill threshold can be chosen responsibly. Deliberately does NOT terminate a slow handshake: one was observed recovering at 43.20h and completing successfully, so a ceiling picked now would destroy recoverable work. Ticks back off to a 5-minute ceiling to bound log volume. Payload carries only stage/elapsed metadata -- no prompts, credentials, environment values, or model output. Refs PEN-1990, PEN-1995, PEN-2324. Signed-off-by: Search <search@example.com>
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Reviewer note — new field evidence since this PR was opened (no diff change)CI is now 20/20 green on Posting this because evidence gathered after opening sharpens why this instrumentation matters — the PR currently undersells it. The unobserved handshake is very likely swallowing a provider signal we already haveA live built-in agent ( Its run spawned 203 ms after that error was recorded and has emitted nothing for 31 h — the exact 3-line stillborn shape this PR instruments. Two timing facts support a capacity-window mechanism rather than a plain hang:
Stalls exit on a boundary; they don't enter on one. That asymmetry is what distinguishes waiting for a capacity window from random latency. Why this argues for merging as-isThe same underlying 429 reaches a So the signal exists and another adapter already acts on it; the ACP path just drops it on the floor. This PR is the minimum change that makes that droppage visible — and the Caveats, unchangedThis is correlational — read from agent records, not a repro and not serving-side logs, and I could not re-read the stalled run's log. A prior instance had capacity explicitly ruled out (it recovered while capacity was still unavailable), so this is not settled. A falsifiable check is scheduled for the None of that changes the review: this PR adds no ceiling, no kill path, and no retry, and a test pins the slow-handshake-still-succeeds property. Whichever way the capacity question lands, the handshake needs to stop being invisible. |
Refs PEN-1990, PEN-1995, PEN-2324.
Thinking Path
Linked Issues or Issue Description
claude_localheartbeat windowsWhy
adapterConfig.timeoutSecwas reported as inert onclaude_local. It is not inert — it measures the wrong interval.packages/adapter-utils/src/acpx-engine/execute.tsarms itsAbortController/setTimeoutat line ~2155, which is afteracpx.sessionis emitted at ~2100. The timer therefore covers the turn. Session establishment —runtime.ensureSession→ agent spawn +session/new|session/load— is awaited at ~1993/2008, before any timer exists.The acpx side does not cover it either. In
acpx@0.12.0/dist/runtime.js:823-862:AcpRuntimeOptions.timeoutMsreaches the set-model RPC and nothing else.That gap is where the stall time lives — 90.56 min, 16.5 h+, and 43.20 h have all been observed between the pre-exec log lines and
acpx.session, with the log frozen at 3 lines throughout.It also explains the runs that exited 0 past their bound (3.4× and 172.7×), which had looked like proof the ceiling was never wired: once the handshake finally returned, the turn completed in ~10 s — well inside 900 s — so the turn timer had nothing to fire on. No PID is recorded on this path either, because acpx spawns its own client and paperclip's
onSpawnnever runs, which is why the process-lost reaper had nothing to reap.What Changed
Observability only — no behavioral change to when a run lives or dies.
runtime.ensureSessioninpackages/adapter-utils/src/acpx-engine/execute.tsso the previously silent handshake emitsacpx.session_establishevents:started, periodicwaiting, thenestablishedorfailed.client.start()versus thesession/new|session/loadhandshake.waitingticks back off to a 5-minute ceiling, so a multi-hour stall costs a few hundred log lines rather than thousands.packages/adapter-utils/src/acpx-engine/execute.test.ts, including one that drives the realexecute()path end-to-end so the wiring is verified rather than only the helper.The operational point is that this keeps the run's last-output timestamp advancing. Today a stalled handshake leaves
lastOutputSeq: 1frozen for hours, which is exactly why these runs are indistinguishable from a dead process and why the process-lost reaper has nothing to act on.What this deliberately does NOT change
It does not terminate a slow handshake. One was observed recovering at 43.20 h and then completing successfully. Any ceiling picked from today's evidence would destroy recoverable work — a 30-minute ceiling would have killed that run 86× early. Choosing a threshold is deliberately left until the data this collects exists. A test asserts a slow handshake still succeeds, so the no-kill property is enforced rather than merely intended.
No change to
timeoutSec, no change to retry policy, no schema migration, no live agent configuration.Relationship to #1279
#1279 (same issues, still open) instruments
runChildProcessand theclaude_localCLI branch. The affected agents take the ACP branch, which returns atclaude-local/src/server/execute.ts:576and calls neither. #1279'sonLifecycleemits nothing on an ACP stillbirth, and its 6 h default is applied inbuildClaudeRuntimeConfig, reached only after that early return. This PR targets the branch the stalls actually occur on. #1279 remains a valid CLI-path improvement and is not superseded by this PR.Verification
packages/adapter-utils/src/acpx-engine/execute.test.ts— 61/61 pass (55 pre-existing + 6 new), so no regression on the liveensureSessionpath.tsc --noEmitonpackages/adapter-utils— clean.execute()path to confirm the wiring.pnpm install --frozen-lockfileaborts in a fresh worktree on an unrelatedopencode-aipostinstall (Failed to install the right opencode CLI package). Test deps land fine butnode_modules/.binis left unlinked, so I ran vitest asnode node_modules/vitest/vitest.mjs. Pre-existing and unrelated to this diff, but it will bite anyone reproducing.Not verified: no live production repro of a stalled handshake, and Reflection Coach is untested (same code path, but unmeasured). This change explains why nothing bounds the hang — not what causes
client.start()to hang, which remains open on PEN-1995.Risks
await; it introduces no kill path, no ceiling, and no retry, so it cannot shorten or terminate a run that would otherwise have succeeded. A test pins the slow-handshake-still-succeeds property.waitingticks add events for as long as a handshake hangs. Mitigated by exponential back-off to a 5-minute ceiling — a 43 h stall produces a few hundred events, not thousands.Model Used
claude-opus-5), 1M-context variant ([1m]), running as Claude Code via the Claude Agent SDK.tsc) used to produce and verify this change.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template0214a7c5(Storybook visual regressionskipped: no UI surface). Thepolicyfailure noted earlier was a GitHub-infra 429/503 downloadingactions/upload-artifact, not this diff; it passed on re-run.🤖 Generated with Claude Code