Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion api/pkg/server/websocket_external_agent_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -3186,7 +3186,22 @@ func (apiServer *HelixAPIServer) sendQueuedPromptToSession(ctx context.Context,
// to manually retry. Pre-2026-04-26 the guard was latent because of
// the ASC/DESC ListInteractions ordering bug fixed in 853492e14;
// fixing the ordering exposed this missing branch.
if !prompt.Interrupt {
//
// BOOT-RACE EXCEPTION to the interrupt exemption: the interrupt bypass is only
// safe once the Zed thread EXISTS. The very first message of a session is sent
// with an empty acp_thread_id — that empty id is what makes Zed create the
// thread. If a second message (even an interrupt) is dispatched before
// thread_created has populated ZedThreadID, it ALSO goes out with an empty
// acp_thread_id and Zed forks a SECOND, divorced thread: the initial message's
// work lands in thread A, the follow-up in thread B, and the agent answers the
// follow-up with "a previous conversation context that I don't have". So until
// the thread is established, even an interrupt must respect the busy-defer; it
// is redelivered into the SAME thread once ZedThreadID is set. The poller-side
// barrier in prompt_history_handlers.go stops the interrupt path; this guards
// the processAnyPendingPrompt / readiness path that funnels here too.
// See design/2026-06-19-incident-interrupt-during-boot-context-loss.md.
threadNotEstablished := session.Metadata.ZedThreadID == ""
if !prompt.Interrupt || threadNotEstablished {
latestInteractions, _, recheckErr := apiServer.Store.ListInteractions(ctx, &types.ListInteractionsQuery{
SessionID: sessionID,
GenerationID: session.GenerationID,
Expand Down
54 changes: 53 additions & 1 deletion design/2026-06-19-incident-interrupt-during-boot-context-loss.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Date:** 2026-06-19
**Severity:** High (silent context loss — agent answers with no task context, no error surfaced)
**Status:** Root-caused, FIXED (two paths), validated end-to-end against live Zed on meta.helix.ml (2026-06-19).
**Status:** Root-caused, FIXED (three guards), content-verified against live Zed on meta.helix.ml (2026-06-19). The first PR (#2660) was **incomplete** — see "Follow-up: the first fix missed a concurrent dispatch path" at the end.
**Affected:** spec task `spt_01kvfq6m8a07gywpj6jmadyrb8`, planning session
`ses_01kvfq6m92snjwagqegd4e1zzk` (`agent_type=zed_external`, `code_agent_runtime=claude_code`).
**Related:** `design/2026-06-19-acp-v2-and-websocket-sync-rewrite-strategy.md`
Expand Down Expand Up @@ -153,3 +153,55 @@ waiting interaction is delivered on the next turn (auto-wake / reconnect).
- The cross-wired 400KB response at 10:41 (response for I1 streaming under the
correction's thread `4186854b`) is the same correlation tangle and is worth a
follow-up once the point fix lands.

---

## Follow-up: the first fix (#2660) missed a concurrent dispatch path

After #2660 merged, the original reproducer (move task to backlog → re-plan from
scratch → quick correction during boot) **failed identically**. Live trace
(session `ses_01kvfv3x…`, 11:44):

- The poller-side barrier (path 1) **did** fire — it deferred the interrupt-path
dispatch 3×. But at **11:44:37** the correction was *also* picked up by a
**different** path — `processAnyPendingPrompt → sendQueuedPromptToSession` —
which is **exempt from the busy-defer for interrupt prompts**. It dispatched the
correction with an **empty `acp_thread_id`** at the same moment the initial went
out (also empty-thread).
- Two empty-thread sends → Zed forked **two** threads: initial's spec work →
`ca419c1b`; correction → `b60a34c8`. Session bound to `b60a34c8`; the agent's
correction response read verbatim *"a previous conversation context that I don't
have."* Same symptom.

**Lesson:** guarding the poller decision site was too narrow — multiple dispatch
paths funnel into `sendQueuedPromptToSession`, and that is the real chokepoint.
Also: the #2660 "validation" checked message *completion + length*, not whether
the agent **retained context** — so it passed while the bug was live. Validate on
response content, not shape.

### The completing guard (chokepoint)

`sendQueuedPromptToSession` (`websocket_external_agent_sync.go`): the interrupt's
exemption from the busy-defer is only safe **once the thread exists**. Gate it:

```go
threadNotEstablished := session.Metadata.ZedThreadID == ""
if !prompt.Interrupt || threadNotEstablished {
// busy-defer: if the newest interaction is a different Waiting one
// (the initial, still creating the thread), return a retryable
// "deferring" error instead of sending a second empty-thread message.
}
```

Until `ZedThreadID` is set, *every* prompt (interrupt included) respects the
busy-defer, so only the genuine first message is ever sent empty-thread. Once the
thread exists the interrupt is exempt again and fires into the SAME thread.

### Content-verified validation (live Zed, 2026-06-19)
Fresh spec task, initial = "WidgetSync … over **Bluetooth**", interrupt during
boot = "Actually … over **WiFi**". Result: session bound to **one** thread
(`04101764`); **zero** empty-thread correction sends; the agent's correction
response: *"The user wants to change the sync mechanism from Bluetooth to WiFi.
Let me now write the spec files for WidgetSync that syncs widgets between devices
over WiFi…"* — i.e. it had the initial feature **and** applied the correction. No
"context I don't have". This is the test that #2660's should have been.
Loading