diff --git a/api/pkg/server/websocket_external_agent_sync.go b/api/pkg/server/websocket_external_agent_sync.go index e8e14a00ac..0db703d2db 100644 --- a/api/pkg/server/websocket_external_agent_sync.go +++ b/api/pkg/server/websocket_external_agent_sync.go @@ -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, diff --git a/design/2026-06-19-incident-interrupt-during-boot-context-loss.md b/design/2026-06-19-incident-interrupt-during-boot-context-loss.md index 54889dd4c4..207767cb9a 100644 --- a/design/2026-06-19-incident-interrupt-during-boot-context-loss.md +++ b/design/2026-06-19-incident-interrupt-during-boot-context-loss.md @@ -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` @@ -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.