fix: trigger LLM execution after /plan start approval

/plan start was a fast-path command that changed MEMORY.md status to
"executing" but never enqueued a message for the LLM worker, so the
agent never actually started working on the plan.

Add planStartPending flag that causes Run() to enqueue a synthetic
message after the fast-path response, triggering an LLM iteration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 00:59:20 +09:00
parent 3f285b0d4b
commit ddd5485298
2 changed files with 43 additions and 4 deletions

View file

@ -38,9 +38,10 @@ type AgentLoop struct {
stats *stats.Tracker // nil when --stats not passed stats *stats.Tracker // nil when --stats not passed
running atomic.Bool running atomic.Bool
summarizing sync.Map summarizing sync.Map
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
providerCache map[string]providers.LLMProvider providerCache map[string]providers.LLMProvider
planStartPending bool // set by /plan start to trigger LLM execution
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -190,6 +191,23 @@ func (al *AgentLoop) Run(ctx context.Context) error {
SkipPlaceholder: true, SkipPlaceholder: true,
}) })
} }
// /plan start sets the flag — enqueue a synthetic message so
// the LLM worker actually begins executing the plan.
if al.planStartPending {
al.planStartPending = false
select {
case llmQueue <- bus.InboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
SenderID: msg.SenderID,
SessionKey: msg.SessionKey,
Content: "The plan has been approved. Begin executing.",
Metadata: msg.Metadata,
}:
case <-ctx.Done():
return nil
}
}
continue continue
} }
@ -1575,6 +1593,7 @@ func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err), true return fmt.Sprintf("Error: %v", err), true
} }
al.planStartPending = true
return "Plan approved. Executing.", true return "Plan approved. Executing.", true
case "next": case "next":

View file

@ -1041,6 +1041,11 @@ func TestPlanCommand_Start(t *testing.T) {
if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" {
t.Errorf("expected 'executing', got %q", status) t.Errorf("expected 'executing', got %q", status)
} }
// planStartPending must be set so Run() enqueues an LLM trigger
if !al.planStartPending {
t.Error("expected planStartPending to be true after /plan start")
}
} }
func TestPlanCommand_StartFromReview(t *testing.T) { func TestPlanCommand_StartFromReview(t *testing.T) {
@ -1062,6 +1067,10 @@ func TestPlanCommand_StartFromReview(t *testing.T) {
if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" {
t.Errorf("expected 'executing', got %q", status) t.Errorf("expected 'executing', got %q", status)
} }
if !al.planStartPending {
t.Error("expected planStartPending to be true after /plan start from review")
}
} }
func TestPlanCommand_StartNoPhases(t *testing.T) { func TestPlanCommand_StartNoPhases(t *testing.T) {
@ -1081,6 +1090,10 @@ func TestPlanCommand_StartNoPhases(t *testing.T) {
if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" {
t.Errorf("expected status to remain 'interviewing', got %q", status) t.Errorf("expected status to remain 'interviewing', got %q", status)
} }
if al.planStartPending {
t.Error("planStartPending must not be set when start is rejected (no phases)")
}
} }
func TestPlanCommand_StartAlreadyExecuting(t *testing.T) { func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
@ -1094,11 +1107,18 @@ func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
_ = agent.ContextBuilder.WriteMemory(plan) _ = agent.ContextBuilder.WriteMemory(plan)
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
// Try start again // Clear the flag from the first call (simulating Run() consuming it)
al.planStartPending = false
// Try start again — should be rejected
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
if !strings.Contains(response, "already executing") { if !strings.Contains(response, "already executing") {
t.Errorf("expected 'already executing', got %q", response) t.Errorf("expected 'already executing', got %q", response)
} }
if al.planStartPending {
t.Error("planStartPending must not be set when plan is already executing")
}
} }
func TestPlanCommand_Done(t *testing.T) { func TestPlanCommand_Done(t *testing.T) {