Files
kandev/apps
João Salavisa 0822d41372 fix(backend): drain orphaned queued messages on agent boot ready (#1096)
* fix(backend): drain orphaned queued messages on agent boot ready

When autoStartStepPrompt hits a transient PromptTask error (e.g. "agent
stream disconnected" mid-prompt), it records the auto-start user message
and then queues the same prompt for retry. The drain only fires from
handleAgentReady (turn-end) — but the failed prompt never reached the
agent, so no agent.ready ever arrives for it. After the user resumes the
session, handleAgentBootReady only flipped state and returned, leaving
the workflow auto-start prompt stuck on the queue forever (the "1 queued"
that wouldn't go away after resume).

handleAgentBootReady now drains via drainQueuedMessageAfterTransition
after the WAITING_FOR_INPUT flip — also on the early-return path where
state was already WAITING (boot signal raced persistResumeState).
Terminal sessions short-circuit before the drain so cancelled/completed
sessions don't revive their queued prompts.

Tests: TestAutoStartTransientError_BootReadyDrainsOrphanedQueue
reproduces the production scenario end-to-end (transient PromptAgent
failure → record + queue → boot_ready → drained). Plus
TestHandleAgentBootReady_DrainsOrphanedQueuedMessage covers both
STARTING→WAITING and the persistResumeState-race path, and
TestHandleAgentBootReady_DoesNotDrainForTerminalSession guards the
terminal-session short-circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): wait for drain goroutine and verify prompt dispatch in repro

Greptile flagged that the queue-empty assertion in
TestAutoStartTransientError_BootReadyDrainsOrphanedQueue raced the
drain's executeQueuedMessage goroutine: the goroutine would re-enter
the same isTransientPromptError path and re-queue the message
immediately, but the assertion would usually win on timing.

Clear agentMgr.promptErr before Phase 2 (simulating the agent
recovering), then poll capturedPrompts until the drained prompt
actually reaches PromptAgent. This turns a timing-fragile assertion
into a deterministic end-to-end check: drain → executeQueuedMessage
→ PromptTask → PromptAgent → queue empty.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): wire executor in boot-ready drain test to fix nil-deref under -race

Claude and cubic flagged that TestHandleAgentBootReady_DrainsOrphanedQueuedMessage
constructed Service without an executor field. The drainQueuedMessageAfterTransition
goroutine spawned by handleAgentBootReady would nil-deref s.executor via
PromptTask -> ensureSessionRunning -> s.executor.GetExecutionBySession.
Passed today by luck of scheduling but would panic under -race.

Wire a real executor (built from the same mockAgentManager and repo as the
other tests) and seed an executors_running row so the drained PromptTask
lands on a working code path instead of crashing. Verified passes with
`go test -race -count=3`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): replace time.Sleep polling with promptDone channel sync in repro

Claude flagged that the polling loops in
TestAutoStartTransientError_BootReadyDrainsOrphanedQueue used time.Sleep
where mockAgentManager.promptDone (closes on first PromptAgent call) gives
a deterministic sync point per AGENTS.md.

- Phase 1: wait on firstPromptCalled, then a 500ms / 1ms tight poll covers
  the synchronous PromptAgent-returns → queue-row-commits gap.
- Phase 2: swap in a fresh secondPromptCalled channel (the mock's
  `first := len(capturedPrompts) == 0` guard requires resetting captured*
  slices so the close re-fires for the drain's second call), then wait on
  that signal before the queue-empty assertion.

Verified with `go test -race -count=5` — all pass, no goroutine leaks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:20:17 +01:00
..
2026-05-25 21:27:20 +00:00