diff options
Diffstat (limited to 'internal/executor')
| -rw-r--r-- | internal/executor/agentmcp.go | 23 | ||||
| -rw-r--r-- | internal/executor/agentmcp_test.go | 41 | ||||
| -rw-r--r-- | internal/executor/channel.go | 70 | ||||
| -rw-r--r-- | internal/executor/channel_test.go | 206 | ||||
| -rw-r--r-- | internal/executor/container_test.go | 3 | ||||
| -rw-r--r-- | internal/executor/executor.go | 7 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 7 | ||||
| -rw-r--r-- | internal/executor/nativerunner_test.go | 45 |
8 files changed, 396 insertions, 6 deletions
diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go index 2177fbd..c0088c5 100644 --- a/internal/executor/agentmcp.go +++ b/internal/executor/agentmcp.go @@ -73,11 +73,17 @@ type recordProgressInput struct { Message string `json:"message" jsonschema:"a short progress note describing what you are doing"` } +type proposeEpicInput struct { + Name string `json:"name" jsonschema:"short descriptive name for the epic; matched by exact name to reuse an existing epic instead of creating a duplicate"` + Description string `json:"description,omitempty" jsonschema:"optional longer description of the initiative"` + StoryIDs []string `json:"story_ids" jsonschema:"the story IDs to group under this epic"` +} + func textResult(text string) *mcp.CallToolResult { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} } -// newAgentServer builds an MCP server exposing the four agent tools bound to ch. +// newAgentServer builds an MCP server exposing the five agent tools bound to ch. func newAgentServer(ch AgentChannel) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{Name: "claudomator", Version: "1"}, nil) @@ -137,6 +143,21 @@ func newAgentServer(ch AgentChannel) *mcp.Server { return textResult("Noted."), nil, nil }) + mcp.AddTool(s, &mcp.Tool{ + Name: "propose_epic", + Description: "Group one or more stories under a new or existing epic (matched by exact name) when they form a cohesive initiative. Only call this when you've been given several story IDs and independently judge that they belong together.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeEpicInput) (*mcp.CallToolResult, any, error) { + id, err := ch.ProposeEpic(ctx, EpicProposal{ + Name: in.Name, + Description: in.Description, + StoryIDs: in.StoryIDs, + }) + if err != nil { + return nil, nil, err + } + return textResult("Proposed epic " + id), nil, nil + }) + return s } diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go index 4aeb7cb..c73d6db 100644 --- a/internal/executor/agentmcp_test.go +++ b/internal/executor/agentmcp_test.go @@ -12,11 +12,13 @@ import ( // recordingChannel is a fake AgentChannel that records tool invocations. type recordingChannel struct { - asked string - summary string - spawned []SubtaskSpec - progress []string - spawnID string + asked string + summary string + spawned []SubtaskSpec + progress []string + spawnID string + proposedEpics []EpicProposal + epicID string } func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) { @@ -35,6 +37,10 @@ func (c *recordingChannel) RecordProgress(_ context.Context, m string) error { c.progress = append(c.progress, m) return nil } +func (c *recordingChannel) ProposeEpic(_ context.Context, spec EpicProposal) (string, error) { + c.proposedEpics = append(c.proposedEpics, spec) + return c.epicID, nil +} func resultText(t *testing.T, res *mcp.CallToolResult) string { t.Helper() @@ -178,6 +184,31 @@ func TestAgentServer_RecordProgress(t *testing.T) { } } +func TestAgentServer_ProposeEpic(t *testing.T) { + ch := &recordingChannel{epicID: "epic-1"} + cs := connectInMemory(t, newAgentServer(ch)) + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "propose_epic", + Arguments: map[string]any{"name": "Checkout revamp", "description": "relaunch", "story_ids": []string{"s1", "s2"}}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(ch.proposedEpics) != 1 { + t.Fatalf("expected 1 proposed epic, got %d", len(ch.proposedEpics)) + } + spec := ch.proposedEpics[0] + if spec.Name != "Checkout revamp" || spec.Description != "relaunch" { + t.Errorf("epic spec not propagated: %+v", spec) + } + if len(spec.StoryIDs) != 2 || spec.StoryIDs[0] != "s1" || spec.StoryIDs[1] != "s2" { + t.Errorf("story_ids not propagated: %+v", spec.StoryIDs) + } + if txt := resultText(t, res); !strings.Contains(txt, "epic-1") { + t.Errorf("expected returned epic ID in result, got %q", txt) + } +} + type bearerRT struct { token string base http.RoundTripper diff --git a/internal/executor/channel.go b/internal/executor/channel.go index 30d826c..ef64ede 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -2,12 +2,15 @@ package executor import ( "context" + "database/sql" "encoding/json" + "errors" "sync" "time" "github.com/thepeterstone/claudomator/internal/agentchannel" "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" ) @@ -29,12 +32,24 @@ type AgentChannel = agentchannel.AgentChannel type SubtaskSpec = agentchannel.SubtaskSpec type BlockedError = agentchannel.BlockedError +// EpicProposal is the Phase 7c sibling of SubtaskSpec above: an alias so +// existing code that refers to executor.EpicProposal (agentmcp.go, tests) +// compiles against the same type agentchannel.AgentChannel.ProposeEpic uses. +type EpicProposal = agentchannel.EpicProposal + var ErrAgentBlocked = agentchannel.ErrAgentBlocked // channelStore is the subset of storage the default channel needs. type channelStore interface { CreateTask(t *task.Task) error CreateEvent(e *event.Event) error + // CreateEpic, GetEpicByName, GetStory, and UpdateStory back + // storeChannel.ProposeEpic (Phase 7c): matching an existing epic by exact + // name (or creating a new one), then attaching the given stories to it. + CreateEpic(e *story.Epic) error + GetEpicByName(name string) (*story.Epic, error) + GetStory(id string) (*story.Story, error) + UpdateStory(st *story.Story) error } // pendingAsker is implemented by channels that buffer an ask_user call so the @@ -153,4 +168,59 @@ func (c *storeChannel) RecordProgress(_ context.Context, message string) error { Actor: event.ActorAgent, Payload: payload, }) +} + +// ProposeEpic groups spec.StoryIDs under a new or existing epic, matched by +// exact spec.Name — the simplest reasonable de-dup; a fuzzy-matching pass is +// left to a later phase if it turns out to matter. Story IDs that don't +// resolve are skipped (not fatal to the call): one bad ID shouldn't prevent +// grouping the rest. The event is attached to the epic's own ID, matching +// the story orchestrator's convention (KindEvalVerdict/KindArbitrationDecided) +// of attaching planning-layer ceremony events to the entity they're about +// rather than to a task ID — events.task_id has no enforced FK, so this is +// exactly the tolerance already built in for that. +func (c *storeChannel) ProposeEpic(_ context.Context, spec agentchannel.EpicProposal) (string, error) { + epic, err := c.store.GetEpicByName(spec.Name) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return "", err + } + epic = &story.Epic{ + ID: uuid.NewString(), + Name: spec.Name, + Description: spec.Description, + DiscoverySource: "agent", + } + if err := c.store.CreateEpic(epic); err != nil { + return "", err + } + } + + grouped := make([]string, 0, len(spec.StoryIDs)) + for _, sid := range spec.StoryIDs { + st, err := c.store.GetStory(sid) + if err != nil { + continue // unresolved story ID: skip, don't fail the whole call + } + st.EpicID = epic.ID + if err := c.store.UpdateStory(st); err != nil { + continue + } + grouped = append(grouped, sid) + } + + payload, _ := json.Marshal(struct { + EpicID string `json:"epic_id"` + Name string `json:"name"` + StoryIDs []string `json:"story_ids"` + }{EpicID: epic.ID, Name: epic.Name, StoryIDs: grouped}) + if err := c.store.CreateEvent(&event.Event{ + TaskID: epic.ID, + Kind: event.KindEpicProposed, + Actor: event.ActorAgent, + Payload: payload, + }); err != nil { + return epic.ID, err + } + return epic.ID, nil }
\ No newline at end of file diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go index 157cb0c..e05228a 100644 --- a/internal/executor/channel_test.go +++ b/internal/executor/channel_test.go @@ -2,10 +2,14 @@ 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/story" "github.com/thepeterstone/claudomator/internal/task" ) @@ -13,6 +17,15 @@ 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 } func (f *fakeChannelStore) CreateTask(t *task.Task) error { @@ -28,6 +41,46 @@ func (f *fakeChannelStore) CreateEvent(e *event.Event) error { 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 TestStoreChannel_AskUser_BuffersAndBlocks(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") answer, err := ch.AskUser(context.Background(), `{"text":"q"}`) @@ -204,3 +257,156 @@ func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { 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) + } +} diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index b06d153..84b3208 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -832,3 +832,6 @@ func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error return "", nil } func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil } +func (noopChannel) ProposeEpic(_ context.Context, _ EpicProposal) (string, error) { + return "", nil +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index ed38d7d..3c41250 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -16,6 +16,7 @@ import ( "github.com/thepeterstone/claudomator/internal/retry" "github.com/thepeterstone/claudomator/internal/role" "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" ) @@ -48,6 +49,12 @@ type Store interface { // ListDependents returns tasks that directly depend on taskID. Used by // cascadeFail to proactively cancel a failed task's downstream subtree. ListDependents(taskID string) ([]*task.Task, error) + // CreateEpic, GetEpicByName, GetStory, and UpdateStory back + // storeChannel.ProposeEpic (Phase 7c) — see internal/executor/channel.go. + CreateEpic(e *story.Epic) error + GetEpicByName(name string) (*story.Epic, error) + GetStory(id string) (*story.Story, error) + UpdateStory(st *story.Story) error } // LogPather is an optional interface runners can implement to provide the log diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index e7e445e..7a7da48 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -14,6 +14,7 @@ import ( "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) @@ -1167,6 +1168,12 @@ func (m *minimalMockStore) GetActiveRoleConfig(_ string) (*storage.RoleConfigRow return nil, sql.ErrNoRows } func (m *minimalMockStore) ListDependents(_ string) ([]*task.Task, error) { return nil, nil } +func (m *minimalMockStore) CreateEpic(_ *story.Epic) error { return nil } +func (m *minimalMockStore) GetEpicByName(_ string) (*story.Epic, error) { + return nil, sql.ErrNoRows +} +func (m *minimalMockStore) GetStory(_ string) (*story.Story, error) { return nil, sql.ErrNoRows } +func (m *minimalMockStore) UpdateStory(_ *story.Story) error { return nil } func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() diff --git a/internal/executor/nativerunner_test.go b/internal/executor/nativerunner_test.go index b081b8e..afdb49d 100644 --- a/internal/executor/nativerunner_test.go +++ b/internal/executor/nativerunner_test.go @@ -19,6 +19,7 @@ import ( "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/provider/openaicompat" "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) @@ -237,6 +238,50 @@ func TestNativeRunner_Run_ToolLoop_SpawnSubtask_RolePassthrough(t *testing.T) { } } +// TestNativeRunner_Run_ToolLoop_ProposeEpic is an end-to-end-ish test (fake +// LLM server, real agentloop.Loop/tools.go dispatch, real storeChannel) +// proving a propose_epic tool call reaches AgentChannel.ProposeEpic +// correctly through internal/agentloop/tools.go's dispatchTool, and from +// there through storeChannel.ProposeEpic into a created epic with the given +// stories grouped under it — mirroring the spawn_subtask role-passthrough +// coverage above (Phase 6) for this phase's new tool (Phase 7c). +func TestNativeRunner_Run_ToolLoop_ProposeEpic(t *testing.T) { + srv := fakeChatServer(t, []fakeTurn{ + {toolCalls: []llm.ToolCall{toolCall("c1", "propose_epic", `{"name":"Checkout revamp","description":"relaunch","story_ids":["story-1","story-2"]}`)}}, + {content: "finished"}, + }) + defer srv.Close() + + r := newLocalRunner(t, srv) + tt := localTask() + store := &fakeChannelStore{ + stories: map[string]*story.Story{ + "story-1": {ID: "story-1", Name: "s1"}, + "story-2": {ID: "story-2", Name: "s2"}, + }, + } + ch := newStoreChannel(store, tt.ID) + exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + + if err := r.Run(context.Background(), tt, exec, ch); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(store.epics) != 1 { + t.Fatalf("expected 1 created epic, got %d", len(store.epics)) + } + var epicID string + for id, e := range store.epics { + epicID = id + if e.Name != "Checkout revamp" || e.Description != "relaunch" { + t.Errorf("epic fields not propagated: %+v", e) + } + } + if store.stories["story-1"].EpicID != epicID || store.stories["story-2"].EpicID != epicID { + t.Errorf("stories not grouped under the proposed epic: %+v / %+v", store.stories["story-1"], store.stories["story-2"]) + } +} + func TestNativeRunner_Run_RecordProgress(t *testing.T) { srv := fakeChatServer(t, []fakeTurn{ {toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}}, |
