Skip to content

fix(sync): report uncorrelated missing-thread failures; E2E timeout-watcher race - #79

Merged
lukemarsden merged 4 commits into
mainfrom
fix/acp-silence-watchdog-state-based
Aug 10, 2026
Merged

fix(sync): report uncorrelated missing-thread failures; E2E timeout-watcher race#79
lukemarsden merged 4 commits into
mainfrom
fix/acp-silence-watchdog-state-based

Conversation

@lukemarsden

Copy link
Copy Markdown

Follow-ups to #74, plus the root-cause fix for a live incident where a session came back with a permanently blank agent panel.

Paired Helix PR (pins ZED_COMMIT): linked below.

1. Report missing-thread failures that have no request_id (the incident fix)

Auto-repair for a reaped/missing ACP thread was already fully built on the Helix side and never fired, because Zed withheld the event that triggers it.

report_thread_open_failure() bailed out unless the request carried a request_id. The case that matters carries none — the open_thread Zed sends automatically on reconnect isn't tied to a user request. So when a task's workspace had been reaped and load_session failed with Resource not found, the error was logged locally and never sent. Confirmed on the live container: grep -c thread_load_error returns 0, despite the load failing.

Helix's recovery gate is:

isAuthoritativeMissingThreadError(errorMsg) && acpThreadID != ""

It never needed a request_id — that's only used for the optional HTTP-streaming reply. So the correlation check gated on something the consumer didn't want, and starved recoverMissingThread of the one event that clears the stale pointer. Result: zed_thread_id kept pointing at a session claude-agent-acp no longer had, every reconnect re-failed identically, and the user saw a blank "New Zed Agent Thread" — which reads as lost agent identity but is a dangling pointer.

Inverts uncorrelated_thread_open_failure_emits_no_event, which asserted the buggy behaviour, into uncorrelated_thread_open_failure_still_reports. The test also pins the "Failed to load thread: " prefix that Helix parses, so the two sides can't drift apart silently.

2. E2E: timeout watcher failed already-successful phases

Caught red-handed, one second apart:

11:43:57 [claude] Phase 9: Received enough completions -- thread did not hang
11:43:58 [claude] PHASE 9 TIMED OUT after 1m30s

The watcher aborts solely on d.phase != phase, but every advance path settles before bumping d.phase — 500ms in the completion handler plus 2s inside advanceAfterCompletion. That ~2.5s window is enough to fail a passing phase whenever completions land near the deadline, which is routine for the slower claude round and likelier under load.

Adds phaseSucceeded, set the moment a success condition is met and checked by the watcher. Deliberately not fixed by raising the timeout — the phase was passing, so a bigger budget would have hidden a real bookkeeping race.

3. E2E: stamp the round's agent on the seed session

The claude round put contradictory identity on the wire (agent_name: "zed-agent" with request_id: "req-phase9-queue-claude"), because the production send path derives the name from getAgentNameForSession() which falls back to "zed-agent" when ZedAgentName is unset. Harness-fidelity gap, not a product bug.

Validation

  • cargo check -p zed --features external_websocket_sync — clean
  • cargo test -p external_websocket_sync — 56 passed
  • E2E: not re-run on this exact tree. An earlier tree reached 9 consecutive green before the run record was lost; these three commits landed after. CI's zed-e2e-test will exercise it post-merge.

🤖 Generated with Claude Code

The claude round was putting mismatched identity on the wire:

  {"agent_name":"zed-agent", "request_id":"req-phase9-queue-claude"}

For a thread already in contextMappings, sendChatMessage deliberately uses the
PRODUCTION send path (srv.SendChatMessage) rather than QueueCommand -- which is
the point, it exercises real code. But that path ignores the agentName the
harness passes and derives it from getAgentNameForSession(), which reads
session.Metadata.ZedAgentName and otherwise falls back to "zed-agent".
websocket_external_agent_sync.go then persists that fallback back onto the
session, so it is sticky.

In production the fallback never fires: the value resolves from the spec task's
app code_agent_runtime. The E2E has no such app, so every session was stamped
"zed-agent" regardless of round, and the claude round was only nominally
claude on that path.

Sessions created during a round inherit ZedAgentName from the originating
session, so stamping the seed session at round start propagates it correctly.

This is a harness-fidelity gap, not a product bug -- recording it as such. It is
NOT yet proven to be the cause of the intermittent Phase 9 claude timeout: the
mismatch was present on every claude run, including six consecutive passes. It
is removed here so the next failure is diagnosed without it as a confounder.

go build: clean.
Root cause of the intermittent "PHASE 9 TIMED OUT" claude-round failures, caught
by running the suite to a target of ten consecutive passes.

The phase-timeout watcher aborts solely on `d.phase != phase`. But every advance
path deliberately settles BEFORE bumping d.phase: 500ms in the completion
handler, plus a further 2s inside advanceAfterCompletion ("let Zed settle").
That leaves a ~2.5s window in which the phase has already succeeded, d.phase is
still the old value, and the watcher will happily declare a timeout.

Caught red-handed in the log -- success and timeout one second apart:

  11:43:57 [claude] Phase 9: Received enough completions -- thread did not hang
  11:43:58 [claude] PHASE 9 TIMED OUT after 1m30s

It shows up in the claude round because claude is slower than zed-agent, so its
completions routinely land near the end of the 90s budget; heavy machine load
widens the window further. The zed-agent round almost always finishes with
seconds to spare, which is why this looked agent-specific rather than structural.

Adds phaseSucceeded, set the moment a success condition is met and checked by
the watcher alongside the existing d.phase comparison. Marked at the top of
advanceAfterCompletion (covering all six callers) and explicitly for phases 8
and 9, which settle before calling it.

Deliberately NOT fixed by enlarging the timeout: the phase was passing, so a
bigger budget would only have hidden a genuine bookkeeping race and made every
real hang take longer to surface.

go build: clean.
Auto-repair for a reaped/missing ACP thread was fully built on the Helix side
and never fired, because Zed withheld the event that triggers it.

report_thread_open_failure() bailed out early unless the ThreadOpenRequest
carried a request_id. The case that matters carries none: the open_thread Zed
sends automatically on reconnect is not tied to a user request. So when a task's
workspace had been reaped and load_session failed with "Resource not found",
the error was logged locally and never sent. Helix kept a zed_thread_id pointing
at a session claude-agent-acp no longer had, every later reconnect re-failed the
same way, and the user saw a permanently blank agent panel labelled
"New Zed Agent Thread" -- which reads as lost agent identity but is actually a
dangling pointer.

Confirmed against the live incident: grep for thread_load_error in that
container's Zed.log returns 0 hits, despite load_session failing.

Helix's recovery gate is:

  isAuthoritativeMissingThreadError(errorMsg) && acpThreadID != ""

It does not require a request_id -- that is only used for the optional
HTTP-streaming reply -- so the correlation check was gating on something the
consumer never needed. Reporting with an empty request_id lets
recoverMissingThread clear the stale pointer, so the next message forks a clean
thread instead of failing forever.

The error string keeps the "Failed to load thread: " prefix that
isAuthoritativeMissingThreadError parses, and the test now asserts that prefix
so the two sides cannot drift apart silently.

Inverts uncorrelated_thread_open_failure_emits_no_event, which asserted the
buggy behaviour, into uncorrelated_thread_open_failure_still_reports.

cargo test -p external_websocket_sync: 56 passed.
cargo check -p zed --features external_websocket_sync: clean.
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

lukemarsden added a commit to helixml/helix that referenced this pull request Aug 10, 2026
Pins helixml/zed#79, which makes the dangling-thread case self-healing.

Together with the reaper fix in this PR that is the full chain for the incident:
Keep Alive is now honoured so the workspace is not deleted in the first place;
and if a thread ever does go missing, Zed now reports it so Helix clears the
stale zed_thread_id automatically instead of showing a blank agent panel.
…dog-state-based

# Conflicts:
#	crates/external_websocket_sync/e2e-test/helix-ws-test-server/main.go
@lukemarsden
lukemarsden merged commit eae9b05 into main Aug 10, 2026
34 checks passed
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