package executor import ( "context" "database/sql" "encoding/json" "errors" "testing" "github.com/thepeterstone/claudomator/internal/agentchannel" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/role" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) type fakeChannelStore struct { createdTasks []*task.Task createdEvents []*event.Event createTaskErr error // Epic/story fakes back ProposeEpic (Phase 7c). epics is keyed by ID; // GetEpicByName does a linear scan by Name, mirroring the real // storage.DB.GetEpicByName's "exact match, simplest reasonable" behavior. epics map[string]*story.Epic createEpicErr error stories map[string]*story.Story updateStoryErr error updatedStories []*story.Story // roleConfigs backs CreateRoleConfig (Phase 8), keyed by role name, in // creation order — mirrors storage.DB.CreateRoleConfig's own // "auto-assign next version number for that role" behavior without a // real database. roleConfigs map[string][]*storage.RoleConfigRow createRoleConfigErr error } func (f *fakeChannelStore) CreateTask(t *task.Task) error { if f.createTaskErr != nil { return f.createTaskErr } f.createdTasks = append(f.createdTasks, t) return nil } func (f *fakeChannelStore) CreateEvent(e *event.Event) error { f.createdEvents = append(f.createdEvents, e) return nil } func (f *fakeChannelStore) CreateEpic(e *story.Epic) error { if f.createEpicErr != nil { return f.createEpicErr } if f.epics == nil { f.epics = make(map[string]*story.Epic) } f.epics[e.ID] = e return nil } func (f *fakeChannelStore) GetEpicByName(name string) (*story.Epic, error) { for _, e := range f.epics { if e.Name == name { return e, nil } } return nil, sql.ErrNoRows } func (f *fakeChannelStore) GetStory(id string) (*story.Story, error) { st, ok := f.stories[id] if !ok { return nil, sql.ErrNoRows } return st, nil } func (f *fakeChannelStore) UpdateStory(st *story.Story) error { if f.updateStoryErr != nil { return f.updateStoryErr } if f.stories == nil { f.stories = make(map[string]*story.Story) } f.stories[st.ID] = st f.updatedStories = append(f.updatedStories, st) return nil } func (f *fakeChannelStore) CreateRoleConfig(roleName, configJSON, proposedBy string) (*storage.RoleConfigRow, error) { if f.createRoleConfigErr != nil { return nil, f.createRoleConfigErr } if f.roleConfigs == nil { f.roleConfigs = make(map[string][]*storage.RoleConfigRow) } version := len(f.roleConfigs[roleName]) + 1 row := &storage.RoleConfigRow{ ID: roleName + "-" + string(rune('0'+version)), Role: roleName, Version: version, Status: "draft", ConfigJSON: configJSON, ProposedBy: proposedBy, } f.roleConfigs[roleName] = append(f.roleConfigs[roleName], row) return row, nil } func TestStoreChannel_AskUser_BuffersAndBlocks(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") answer, err := ch.AskUser(context.Background(), `{"text":"q"}`) if !errors.Is(err, ErrAgentBlocked) { t.Errorf("expected ErrAgentBlocked, got %v", err) } if answer != "" { t.Errorf("expected empty answer, got %q", answer) } q, blocked := ch.PendingQuestion() if !blocked || q != `{"text":"q"}` { t.Errorf("expected buffered question, got q=%q blocked=%v", q, blocked) } } func TestStoreChannel_PendingQuestion_DefaultNotBlocked(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") if q, blocked := ch.PendingQuestion(); blocked || q != "" { t.Errorf("expected no pending question, got q=%q blocked=%v", q, blocked) } } func TestStoreChannel_ReportSummary_Buffers(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") if _, ok := ch.ReportedSummary(); ok { t.Error("expected no summary before report") } if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { t.Fatalf("ReportSummary: %v", err) } sum, ok := ch.ReportedSummary() if !ok || sum != "did the thing" { t.Errorf("expected buffered summary, got %q ok=%v", sum, ok) } } func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "child", Instructions: "do it", Model: "sonnet", MaxBudgetUSD: 1.5, }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if id == "" { t.Error("expected non-empty subtask ID") } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ParentTaskID != "parent-1" { t.Errorf("ParentTaskID: got %q want parent-1", ct.ParentTaskID) } if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.Agent.Instructions != "do it" || ct.Agent.Model != "sonnet" || ct.Agent.MaxBudgetUSD != 1.5 { t.Errorf("agent config not propagated: %+v", ct.Agent) } if ct.State != task.StatePending { t.Errorf("expected PENDING child, got %s", ct.State) } } // TestStoreChannel_SpawnSubtask_RoleSet verifies that when spec.Role is set, // the child task gets Agent.Role populated and Agent.Type/Model left empty // so executor.Pool.execute()'s role-based dispatch (Phase 5) resolves them // from the role's escalation ladder, exactly like any other role-typed task. func TestStoreChannel_SpawnSubtask_RoleSet(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "evaluator", Instructions: "evaluate the change", Model: "should-be-ignored", MaxBudgetUSD: 2.0, Role: "evaluator_quality", }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.Agent.Role != "evaluator_quality" { t.Errorf("Agent.Role: want evaluator_quality, got %q", ct.Agent.Role) } if ct.Agent.Type != "" { t.Errorf("Agent.Type: want empty (resolved later by role dispatch), got %q", ct.Agent.Type) } if ct.Agent.Model != "" { t.Errorf("Agent.Model: want empty (resolved later by role dispatch), got %q", ct.Agent.Model) } if ct.Agent.Instructions != "evaluate the change" || ct.Agent.MaxBudgetUSD != 2.0 { t.Errorf("non-role fields not propagated: %+v", ct.Agent) } } // TestStoreChannel_SpawnSubtask_NoRole_RegressionUnchanged is a regression // test proving that spec.Role == "" (every pre-Phase-6 caller) still // produces exactly today's behavior: Agent.Type hardcoded to "claude" and // Agent.Model set from spec.Model. This must keep passing unmodified by any // future change to role-typed spawning. func TestStoreChannel_SpawnSubtask_NoRole_RegressionUnchanged(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "child", Instructions: "do it", Model: "sonnet", MaxBudgetUSD: 1.5, }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.ParentTaskID != "parent-1" { t.Errorf("ParentTaskID: got %q want parent-1", ct.ParentTaskID) } if ct.Agent.Type != "claude" { t.Errorf("Agent.Type: want claude (unchanged backward-compat default), got %q", ct.Agent.Type) } if ct.Agent.Model != "sonnet" { t.Errorf("Agent.Model: want sonnet, got %q", ct.Agent.Model) } if ct.Agent.Role != "" { t.Errorf("Agent.Role: want empty, got %q", ct.Agent.Role) } if ct.Agent.Instructions != "do it" || ct.Agent.MaxBudgetUSD != 1.5 { t.Errorf("agent config not propagated: %+v", ct.Agent) } if ct.State != task.StatePending { t.Errorf("expected PENDING child, got %s", ct.State) } } func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { store := &fakeChannelStore{createTaskErr: errors.New("boom")} ch := newStoreChannel(store, "parent-1") if _, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{Name: "x"}); err == nil { t.Error("expected error from CreateTask") } } // TestStoreChannel_SpawnSubtask_SetsDependsOn proves a caller can chain a // new subtask after a sibling it already spawned, by passing the sibling's // returned ID back in DependsOn -- the mechanism a decomposing task uses to // express "this step must wait for that one", not just fan-out-and-wait. func TestStoreChannel_SpawnSubtask_SetsDependsOn(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-task-1") firstID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ Name: "step 1", Instructions: "do the first thing", }) if err != nil { t.Fatalf("spawn first subtask: %v", err) } secondID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ Name: "step 2", Instructions: "do the second thing", DependsOn: []string{firstID}, }) if err != nil { t.Fatalf("spawn second subtask: %v", err) } var second *task.Task for _, ct := range store.createdTasks { if ct.ID == secondID { second = ct } } if second == nil { t.Fatal("second subtask not found in store.createdTasks") } if len(second.DependsOn) != 1 || second.DependsOn[0] != firstID { t.Errorf("expected DependsOn=[%q], got %v", firstID, second.DependsOn) } } // TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty proves the // backward-compatible default: a caller that never sets DependsOn (every // pre-existing caller) gets a child task with no dependencies, exactly as // before this change. func TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-task-1") id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ Name: "solo step", Instructions: "do the thing", }) if err != nil { t.Fatalf("spawn subtask: %v", err) } var got *task.Task for _, ct := range store.createdTasks { if ct.ID == id { got = ct } } if got == nil { t.Fatal("subtask not found in store.createdTasks") } if len(got.DependsOn) != 0 { t.Errorf("expected empty DependsOn by default, got %v", got.DependsOn) } } func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "task-1") if err := ch.RecordProgress(context.Background(), "halfway done"); err != nil { t.Fatalf("RecordProgress: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } e := store.createdEvents[0] if e.Kind != event.KindAgentMessage || e.Actor != event.ActorAgent { t.Errorf("got kind=%v actor=%v want agent_message/agent", e.Kind, e.Actor) } if e.TaskID != "task-1" { t.Errorf("TaskID: got %q want task-1", e.TaskID) } } // TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories proves (a) from // the phase description: a new epic name creates a fresh epics row and sets // epic_id on the given stories. func TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, "story-2": {ID: "story-2", Name: "s2"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", Description: "Everything to relaunch checkout", StoryIDs: []string{"story-1", "story-2"}, }) if err != nil { t.Fatalf("ProposeEpic: %v", err) } if epicID == "" { t.Fatal("expected non-empty epic ID") } if len(store.epics) != 1 { t.Fatalf("expected 1 created epic, got %d", len(store.epics)) } epic := store.epics[epicID] if epic == nil { t.Fatalf("epic %q not found in store", epicID) } if epic.Name != "Checkout revamp" || epic.Description != "Everything to relaunch checkout" { t.Errorf("epic fields not propagated: %+v", epic) } if epic.DiscoverySource != "agent" { t.Errorf("DiscoverySource: want agent, got %q", epic.DiscoverySource) } if store.stories["story-1"].EpicID != epicID || store.stories["story-2"].EpicID != epicID { t.Errorf("stories not grouped under epic: %+v / %+v", store.stories["story-1"], store.stories["story-2"]) } if len(store.updatedStories) != 2 { t.Errorf("expected 2 UpdateStory calls, got %d", len(store.updatedStories)) } } // TestStoreChannel_ProposeEpic_ReusesExistingEpicByName proves (b): calling // ProposeEpic again with the same name reuses the existing epic rather than // creating a duplicate. func TestStoreChannel_ProposeEpic_ReusesExistingEpicByName(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, "story-3": {ID: "story-3", Name: "s3"}, }, } ch := newStoreChannel(store, "task-1") firstID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1"}, }) if err != nil { t.Fatalf("first ProposeEpic: %v", err) } secondID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-3"}, }) if err != nil { t.Fatalf("second ProposeEpic: %v", err) } if firstID != secondID { t.Errorf("expected the same epic ID to be reused, got %q then %q", firstID, secondID) } if len(store.epics) != 1 { t.Fatalf("expected exactly 1 epic (no duplicate), got %d", len(store.epics)) } if store.stories["story-3"].EpicID != firstID { t.Errorf("story-3 not grouped under the reused epic: %+v", store.stories["story-3"]) } } // TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs proves that a story ID // which doesn't resolve is skipped rather than failing the whole call. func TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1", "does-not-exist"}, }) if err != nil { t.Fatalf("ProposeEpic should not fail on one bad story ID: %v", err) } if store.stories["story-1"].EpicID != epicID { t.Errorf("story-1 should still be grouped: %+v", store.stories["story-1"]) } if len(store.updatedStories) != 1 { t.Errorf("expected exactly 1 UpdateStory call (the bad ID skipped), got %d", len(store.updatedStories)) } } // TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent proves (c): a // KindEpicProposed event is emitted, attached to the epic's own ID (not a // task ID), with a payload naming the epic and the stories actually grouped. func TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1", "missing"}, }) if err != nil { t.Fatalf("ProposeEpic: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } ev := store.createdEvents[0] if ev.Kind != event.KindEpicProposed || ev.Actor != event.ActorAgent { t.Errorf("got kind=%v actor=%v want epic_proposed/agent", ev.Kind, ev.Actor) } if ev.TaskID != epicID { t.Errorf("event should be attached to the epic ID %q, got %q", epicID, ev.TaskID) } var payload struct { EpicID string `json:"epic_id"` Name string `json:"name"` StoryIDs []string `json:"story_ids"` } if err := json.Unmarshal(ev.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.EpicID != epicID || payload.Name != "Checkout revamp" { t.Errorf("payload epic fields: %+v", payload) } if len(payload.StoryIDs) != 1 || payload.StoryIDs[0] != "story-1" { t.Errorf("payload.StoryIDs should only include the resolved story, got %+v", payload.StoryIDs) } } // TestStoreChannel_ProposeRoleConfig_CreatesDraftRow proves verification // item (c): calling ProposeRoleConfig for a role creates a new // draft-status role_configs row without touching whatever's currently // active (the fake store here has no notion of "active" at all -- draft // creation via CreateRoleConfig never reads or writes activation state, // mirroring storage.CreateRoleConfig/ActivateRoleConfigVersion being // entirely separate code paths). func TestStoreChannel_ProposeRoleConfig_CreatesDraftRow(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "retro-task-1") version, err := ch.ProposeRoleConfig(context.Background(), role.RoleConfig{ Role: "builder", SystemPrompt: "Be more careful about edge cases.", }) if err != nil { t.Fatalf("ProposeRoleConfig: %v", err) } if version != 1 { t.Errorf("expected version 1 for a role with no existing versions, got %d", version) } rows := store.roleConfigs["builder"] if len(rows) != 1 { t.Fatalf("expected 1 role_configs row created, got %d", len(rows)) } row := rows[0] if row.Status != "draft" { t.Errorf("expected status draft, got %q", row.Status) } if row.ProposedBy != "retro" { t.Errorf("expected proposed_by retro, got %q", row.ProposedBy) } var decoded role.RoleConfig if err := json.Unmarshal([]byte(row.ConfigJSON), &decoded); err != nil { t.Fatalf("unmarshal config_json: %v", err) } if decoded.Role != "builder" || decoded.SystemPrompt != "Be more careful about edge cases." { t.Errorf("config_json round-trip mismatch: %+v", decoded) } } // TestStoreChannel_ProposeRoleConfig_VersionsIncrement proves a second // proposal for the same role gets the next version number, mirroring // storage.CreateRoleConfig's own auto-increment behavior. func TestStoreChannel_ProposeRoleConfig_VersionsIncrement(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "retro-task-1") v1, err := ch.ProposeRoleConfig(context.Background(), role.RoleConfig{Role: "builder"}) if err != nil { t.Fatalf("first ProposeRoleConfig: %v", err) } v2, err := ch.ProposeRoleConfig(context.Background(), role.RoleConfig{Role: "builder"}) if err != nil { t.Fatalf("second ProposeRoleConfig: %v", err) } if v1 != 1 || v2 != 2 { t.Errorf("expected versions 1 then 2, got %d then %d", v1, v2) } } // TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent proves // verification item (d)'s prerequisite: each ProposeRoleConfig call records // a KindRoleConfigProposed event attached to the *calling task's* ID (not a // role or story ID -- roles have no first-class row of their own), payload // {role, version}, which internal/scheduler.StoryOrchestrator's // finalizeRetro later aggregates into KindRetroCaptured. func TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "retro-task-1") version, err := ch.ProposeRoleConfig(context.Background(), role.RoleConfig{Role: "evaluator_quality"}) if err != nil { t.Fatalf("ProposeRoleConfig: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } ev := store.createdEvents[0] if ev.Kind != event.KindRoleConfigProposed || ev.Actor != event.ActorAgent { t.Errorf("got kind=%v actor=%v want role_config_proposed/agent", ev.Kind, ev.Actor) } if ev.TaskID != "retro-task-1" { t.Errorf("event should be attached to the calling task ID, got %q", ev.TaskID) } var payload struct { Role string `json:"role"` Version int `json:"version"` } if err := json.Unmarshal(ev.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.Role != "evaluator_quality" || payload.Version != version { t.Errorf("payload mismatch: %+v (want role=evaluator_quality version=%d)", payload, version) } } // TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent proves calling // ReportVerdict records a KindVerdictReported event attached to the // *calling task's* own ID, payload {approved, reasoning} — // internal/scheduler.StoryOrchestrator's finalizeArbitration later reads an // arbitration task's own event stream for this to decide REVIEW_READY vs // NEEDS_FIX. Mirrors TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent. func TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "arbitration-task-1") if err := ch.ReportVerdict(context.Background(), false, "the CSS change breaks the running-log panel"); err != nil { t.Fatalf("ReportVerdict: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } ev := store.createdEvents[0] if ev.Kind != event.KindVerdictReported { t.Errorf("expected KindVerdictReported, got %q", ev.Kind) } if ev.Actor != event.ActorAgent { t.Errorf("expected ActorAgent, got %q", ev.Actor) } if ev.TaskID != "arbitration-task-1" { t.Errorf("expected event attached to calling task ID, got %q", ev.TaskID) } var payload struct { Approved bool `json:"approved"` Reasoning string `json:"reasoning"` } if err := json.Unmarshal(ev.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.Approved != false || payload.Reasoning != "the CSS change breaks the running-log panel" { t.Errorf("unexpected payload: %+v", payload) } } // TestStoreChannel_ReportVerdict_Approved proves the approved=true case // round-trips correctly too — not just the rejection path exercised above. func TestStoreChannel_ReportVerdict_Approved(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "arbitration-task-2") if err := ch.ReportVerdict(context.Background(), true, "meets all acceptance criteria"); err != nil { t.Fatalf("ReportVerdict: %v", err) } var payload struct { Approved bool `json:"approved"` Reasoning string `json:"reasoning"` } if err := json.Unmarshal(store.createdEvents[0].Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if !payload.Approved { t.Error("expected approved=true to round-trip as true") } }