0b2c59a2dd
* 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>
240 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|