Files
kandev/apps/backend/internal/agent/runtime/lifecycle/wakeup_simulated_test.go
T
João Salavisa 0b2c59a2dd fix(lifecycle): publish AgentReady on wakeup-driven turns (#875)
* fix(lifecycle): publish AgentReady on wakeup-driven turns

Wakeup turns flow Adapter.Prompt → bridge → events, bypassing
SessionManager.SendPrompt. The execution status stayed Ready across
the wakeup turn, so MarkReady's duplicate-suppression guard dropped
the second AgentReady — the orchestrator never closed the turn,
never evaluated on_turn_complete, and never dispatched queued
messages. Detect this in recordActivity and flip Ready → Running on
the first non-terminal event, re-emitting AgentRunning so the rest
of the pipeline behaves like any other turn.

* fix(lifecycle): gate wakeup flip on turn-content events and handle empty turns

Three follow-ups on the wakeup AgentReady fix:

1. Restrict the Ready → Running flip in `recordActivity` to events that
   unambiguously indicate turn-in-progress activity (message_chunk,
   reasoning, tool_call/update, plan, agent_plan, permission_request).
   The original whole-event-type flip caused a regression: claude-agent-acp
   emits available_commands_update asynchronously ~50ms after session/new,
   well after dispatchInitialPrompt fires MarkBootReady for a no-prompt
   task. The Ready → Running flip on that delayed metadata event was
   putting newly-created tasks back into Running and breaking the chat-
   input UI (changes-panel-focus E2E tests).

2. Handle the empty-wakeup edge case: a wakeup turn that emits only a
   `complete` event (empty model response) is still excluded from the
   flip whitelist, so the Ready guard in MarkReady would suppress
   AgentReady. `handleCompleteEventMarkState` now publishes AgentReady
   directly when Status is already Ready on a successful complete —
   the orchestrator still closes the turn for empty wakeup responses.

3. Fold the `executionStore != nil` check into the early-return so the
   AgentRunning publish only fires when the status flip actually succeeded.

Test-file cleanup from review feedback: dead loop, redundant
`var _ = io.MultiReader`, and `teeReader.src any` → `io.Reader`.

New regression tests:
- TestWakeup_PostBootMetadataEventsDoNotFlipToRunning — locks in the
  whitelist behavior so future events can't accidentally re-arm a
  boot-ready session.
- TestWakeup_EmptyTurnStillPublishesAgentReady — covers the
  empty-wakeup-complete fallback.

* fix(lifecycle): publish AgentRunning before AgentReady on empty wakeup turns

Follow-up on the previous wakeup fixes — addresses three issues raised in
review:

1. **Empty-wakeup AgentReady was ignored by the orchestrator.** The fallback
   in `handleCompleteEventMarkState` published AgentReady when Status was
   already Ready, but `handleAgentReady` at the orchestrator early-returns
   when `session.State != Running/Starting`. After the previous turn ended
   the session is in WaitingForInput, so the AgentReady got silently
   dropped — same suppression the wakeup fix was meant to prevent. Now the
   fallback flips Ready → Running + publishes AgentRunning first (mirroring
   `recordActivity` for the non-empty case), then falls through to MarkReady.

2. **firstActivityOnce could still publish AgentRunning on a late
   metadata event.** Gate it on `Status != Ready` so a delayed
   `available_commands_update` arriving after MarkBootReady (the original
   regression scenario) can't slip through via the once-per-execution path.
   In practice the adapter always emits `agent_capabilities` during
   Initialize before MarkBootReady, so firstActivityOnce still fires there
   with Status==Running — this is defensive hardening.

3. **Tighter test assertions.** The post-boot-metadata test now primes
   firstActivityOnce in setup and asserts strict `runningCount == 0`.
   The second-turn test verifies AgentRunning precedes each AgentReady in
   bus order so the orchestrator's session-state guard isn't violated.
   Same ordering check added to the empty-wakeup test.

* test(lifecycle): rename exec to agentExec to avoid os/exec shadow

The test variable `exec` in wakeup_e2e_test.go shadowed the imported
`os/exec` package — harmless today because all `exec.Command`/`LookPath`
calls happen before the local binding, but a future call added below
would silently fail. Rename to `agentExec` to remove the shadow.

* ci: retrigger CodeQL after transient Ruby analyze flake

The default-setup CodeQL run on PR #875 produced a flaky 'CodeQL could
not process any code written in Ruby' failure on the Ruby analyze job
(github.com/kdlbs/kandev/actions/runs/26124163769). The repo has no
Ruby code and main's CodeQL only analyzes javascript-typescript +
actions; the Ruby job was added dynamically and failed on the empty
diff. Sibling run 26124163815 on the same SHA passed. Cannot rerun
default-setup workflows via the API (403 'cannot be retried'), so
push an empty commit to retrigger the analysis.

* test(lifecycle): tighten wakeup test assertions

- wakeup_simulated_test.go: assert exact readyCount==2 instead of <2 so
  any over-emission (e.g. duplicate AgentReady on the wakeup turn) is
  also caught.
- wakeup_e2e_test.go: fail with t.Fatalf when the adapter surfaces no
  WAKEUP text instead of logging a warning. Otherwise the bus-level
  assertions below pass vacuously if the wakeup never fires.

Both fixes address review comments from cubic-dev-ai / claude[bot] on
PR #875.

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

* test(lifecycle): remove unsynchronised SessionID write in forwarder goroutine

The forwarding goroutine wrote to agentExec.SessionID without holding
the executionStore mutex, racing with the main goroutine's authoritative
assignment after NewSession returns. The forwarder's write is redundant
— line 264 sets the canonical value — and benign in practice, but
`go test -race` would flag it. Drop the write to keep the race detector
clean.

Addresses review comment from claude[bot] on PR #875.

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-21 01:37:03 +01:00

240 lines
10 KiB
Go

package lifecycle
import (
"testing"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/events"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// TestWakeup_SecondTurnAgentReadyIsSuppressed reproduces the bug deterministically
// without needing the bridge or auth.
//
// Scenario: an execution receives events for two consecutive turns. The first
// turn is user-initiated (status flips Running → Ready on complete, agent.ready
// fires). The second turn is wakeup-initiated — kandev's wakeup scheduler calls
// adapter.Prompt directly, which does NOT flip the execution back to Running
// (that's done by SessionManager.SendPrompt, which fireWakeup bypasses).
//
// When the second `complete` event arrives, the manager calls MarkReady. But
// MarkReady at manager_interaction.go:896 has:
//
// if execution.Status == v1.AgentStatusReady {
// return nil
// }
//
// Since the execution is still Ready (never went back to Running), this early-
// returns, no agent.ready is published, and the orchestrator never sees the
// wakeup turn end → completeTurnForSession never fires → workflow on_turn_complete
// never evaluates.
//
// The wakeup-turn's message_streaming events DO still reach the bus (those
// don't gate on execution status), so the persisted chat history is correct.
// But workflow state and queued-message dispatch are broken.
func TestWakeup_SecondTurnAgentReadyIsSuppressed(t *testing.T) {
mgr, eventBus := createTestManagerWithTracking()
execution := createTestExecution("exec-1", "task-1", "session-1")
_ = mgr.executionStore.Add(execution)
// === Turn 1: user-initiated prompt ===
//
// In production: SessionManager.SendPrompt() runs and calls
// sm.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
// We mirror that here.
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
// Assistant text + tool call + complete (the agent calls ScheduleWakeup).
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
Type: "message_chunk", Text: "Scheduled.\n",
})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
// === Turn 2: wakeup-initiated prompt ===
//
// In production: the wakeupScheduler timer fires → adapter.fireWakeup() →
// adapter.Prompt(ctx, prompt, nil). The lifecycle layer is NOT involved
// in initiating this prompt, so executionStore.UpdateStatus(Running) is
// NEVER called. We mirror that by leaving the execution status as-is.
// Wakeup-turn text + complete arrive via the adapter's update stream.
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
Type: "message_chunk", Text: "WAKEUP_FIRED\n",
})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
// === Inspect what the manager published ===
readyCount := 0
streamingTexts := []string{}
streamCompleteCount := 0
for _, te := range eventBus.PublishedEvents {
if te.Event == nil {
continue
}
if te.Event.Type == events.AgentReady {
readyCount++
}
if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok && payload.Data != nil {
switch payload.Data.Type {
case "message_streaming":
streamingTexts = append(streamingTexts, payload.Data.Text)
case "complete":
streamCompleteCount++
}
}
}
t.Logf("agent.ready events published: %d (expect 2)", readyCount)
t.Logf("message_streaming events: %v", streamingTexts)
t.Logf("AgentStreamEventPayload type=complete events: %d (expect 2)", streamCompleteCount)
t.Logf("execution.Status at end: %s", execution.Status)
// The agent-stream-level events (message_streaming, complete) are NOT
// gated on execution status — they should reach the bus for BOTH turns.
if streamCompleteCount != 2 {
t.Errorf("expected 2 stream-level complete events, got %d", streamCompleteCount)
}
if len(streamingTexts) != 2 {
t.Errorf("expected 2 message_streaming events (one per turn), got %d: %v",
len(streamingTexts), streamingTexts)
}
// The bug: agent.ready is suppressed on the second turn because the
// execution status is already Ready when MarkReady runs.
if readyCount != 2 {
t.Errorf(
"expected exactly 2 agent.ready events, got %d. "+
"MarkReady at manager_interaction.go:896 suppresses the wakeup turn's "+
"AgentReady because fireWakeup bypasses SessionManager.SendPrompt and "+
"never flips the execution back to Running. The orchestrator therefore "+
"never receives agent.ready for the wakeup turn → "+
"completeTurnForSession is not called → workflow on_turn_complete is "+
"not evaluated → queued messages are not dispatched.",
readyCount,
)
}
// Ordering check: for each AgentReady, there must be a preceding
// AgentRunning. The orchestrator's handleAgentReady early-returns when
// session.State is not Running/Starting, so an AgentReady on the bus
// is only useful if the matching AgentRunning has flipped session
// state first. Verify by walking the published events in order and
// requiring `running_count >= ready_count` at every AgentReady index.
running, ready := 0, 0
for _, te := range eventBus.PublishedEvents {
if te.Event == nil {
continue
}
switch te.Event.Type {
case events.AgentRunning:
running++
case events.AgentReady:
ready++
if running < ready {
t.Errorf("AgentReady #%d published before matching AgentRunning (running=%d at that point) — orchestrator's handleAgentReady will silently drop this", ready, running)
}
}
}
}
// TestWakeup_PostBootMetadataEventsDoNotFlipToRunning verifies that boot-time
// metadata events (available_commands_update, agent_capabilities, context_window
// etc.) arriving AFTER MarkBootReady has put the execution into Ready do NOT
// re-arm the execution as Running. claude-agent-acp emits
// available_commands_update asynchronously ~50ms after session/new — well
// after dispatchInitialPrompt fires MarkBootReady for a no-prompt task —
// and an over-eager Ready → Running flip on that event would put the session
// into Running state in the orchestrator and break the chat-input UI for
// freshly-created tasks awaiting their first user message.
func TestWakeup_PostBootMetadataEventsDoNotFlipToRunning(t *testing.T) {
mgr, eventBus := createTestManagerWithTracking()
execution := createTestExecution("exec-1", "task-1", "session-1")
_ = mgr.executionStore.Add(execution)
// Prime firstActivityOnce so it's already consumed before the post-boot
// metadata events arrive. In production, the adapter always emits
// `agent_capabilities` during Initialize (before MarkBootReady), which
// consumes firstActivityOnce while Status is still Running. The
// `Sync.Once.Do` call here mirrors that.
execution.firstActivityOnce.Do(func() {})
// Mirror the no-prompt boot path: dispatchInitialPrompt → MarkBootReady →
// status flips to Ready.
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusReady)
// Boot metadata events arrive after MarkBootReady. None of these
// indicate a new turn — they're protocol metadata.
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "available_commands"})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "agent_capabilities"})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "session_mode"})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "session_models"})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "context_window"})
runningCount := 0
for _, te := range eventBus.PublishedEvents {
if te.Event != nil && te.Event.Type == events.AgentRunning {
runningCount++
}
}
// With firstActivityOnce already consumed, the only way to publish
// AgentRunning would be the post-boot whitelist flip. None of these
// metadata events are on the whitelist, so the count must be exactly 0.
if runningCount != 0 {
t.Errorf("expected 0 agent.running events from post-boot metadata, got %d — metadata-driven re-arming is broken", runningCount)
}
if execution.Status != v1.AgentStatusReady {
t.Errorf("execution.Status = %q, want %q — boot metadata events should not change status", execution.Status, v1.AgentStatusReady)
}
}
// TestWakeup_EmptyTurnStillPublishesAgentReady covers the narrow edge case
// where a wakeup turn produces *only* a `complete` event (no preceding
// message_chunk/tool_call/etc) — e.g. when the model returns an empty
// response on wake-up. recordActivity skips the flip on `complete` events
// (they're outside the turn-content whitelist), so handleCompleteEventMarkState
// must publish AgentReady directly when it detects Status is already Ready.
func TestWakeup_EmptyTurnStillPublishesAgentReady(t *testing.T) {
mgr, eventBus := createTestManagerWithTracking()
execution := createTestExecution("exec-1", "task-1", "session-1")
_ = mgr.executionStore.Add(execution)
// Turn 1: normal user turn.
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "ok\n"})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
// Turn 2: wakeup turn with ONLY a complete event — the agent woke up
// and immediately decided it had nothing to say.
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
readyCount := 0
for _, te := range eventBus.PublishedEvents {
if te.Event != nil && te.Event.Type == events.AgentReady {
readyCount++
}
}
if readyCount != 2 {
t.Errorf("expected 2 agent.ready events (one per turn, including the empty wakeup turn), got %d", readyCount)
}
// The empty-wakeup fallback in handleCompleteEventMarkState must publish
// AgentRunning before AgentReady so the orchestrator's session-state
// guard (handleAgentReady early-returns on non-Running/Starting state)
// lets AgentReady through.
running, ready := 0, 0
for _, te := range eventBus.PublishedEvents {
if te.Event == nil {
continue
}
switch te.Event.Type {
case events.AgentRunning:
running++
case events.AgentReady:
ready++
if running < ready {
t.Errorf("AgentReady #%d for empty wakeup published before matching AgentRunning — orchestrator will drop it", ready)
}
}
}
}