summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/agentmcp.go37
-rw-r--r--internal/executor/agentmcp_test.go60
-rw-r--r--internal/executor/channel.go49
-rw-r--r--internal/executor/channel_test.go128
-rw-r--r--internal/executor/container_test.go4
-rw-r--r--internal/executor/executor.go3
-rw-r--r--internal/executor/executor_test.go3
-rw-r--r--internal/executor/nativerunner_test.go59
8 files changed, 336 insertions, 7 deletions
diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go
index c0088c5..a89c670 100644
--- a/internal/executor/agentmcp.go
+++ b/internal/executor/agentmcp.go
@@ -6,11 +6,13 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "fmt"
"net/http"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/role"
)
// Registry maps per-task MCP bearer tokens to a built agent MCP server. A token
@@ -79,6 +81,30 @@ type proposeEpicInput struct {
StoryIDs []string `json:"story_ids" jsonschema:"the story IDs to group under this epic"`
}
+// proposeRoleConfigInput mirrors internal/role.RoleConfig's fields directly
+// (same json tags) rather than defining a parallel shape, so the tool's
+// input decodes straight into a role.RoleConfig with no field-by-field
+// translation. See propose_role_config below.
+type proposeRoleConfigInput struct {
+ Role string `json:"role" jsonschema:"the role name this config applies to (e.g. an existing role like builder, or a new one)"`
+ SystemPrompt string `json:"system_prompt,omitempty" jsonschema:"system prompt appended for tasks dispatched through this role"`
+ Tools []string `json:"tools,omitempty" jsonschema:"optional tool allowlist for this role"`
+ SandboxKind string `json:"sandbox_kind,omitempty" jsonschema:"optional sandbox kind for this role"`
+ DefaultBudgetUSD float64 `json:"default_budget_usd,omitempty" jsonschema:"optional estimated budget in USD, used when the scheduler considers escalating this role's tasks"`
+ EscalationLadder []role.Tier `json:"escalation_ladder,omitempty" jsonschema:"ordered list of escalation tiers; each has candidates (provider/model pairs), an optional selection_mode (round_robin|single), and max_retries"`
+}
+
+func (in proposeRoleConfigInput) toRoleConfig() role.RoleConfig {
+ return role.RoleConfig{
+ Role: in.Role,
+ SystemPrompt: in.SystemPrompt,
+ Tools: in.Tools,
+ SandboxKind: in.SandboxKind,
+ DefaultBudgetUSD: in.DefaultBudgetUSD,
+ EscalationLadder: in.EscalationLadder,
+ }
+}
+
func textResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
}
@@ -158,6 +184,17 @@ func newAgentServer(ch AgentChannel) *mcp.Server {
return textResult("Proposed epic " + id), nil, nil
})
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "propose_role_config",
+ Description: "Propose a new draft configuration version for a role, after reflecting on what happened (e.g. during a story retro). Creates a new draft role_configs row for a human to review and activate via POST /api/roles/{role}/activate -- it never changes what is currently active.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeRoleConfigInput) (*mcp.CallToolResult, any, error) {
+ version, err := ch.ProposeRoleConfig(ctx, in.toRoleConfig())
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult(fmt.Sprintf("Proposed role config %s v%d (draft)", in.Role, version)), nil, nil
+ })
+
return s
}
diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go
index c73d6db..18299e8 100644
--- a/internal/executor/agentmcp_test.go
+++ b/internal/executor/agentmcp_test.go
@@ -8,17 +8,20 @@ import (
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/role"
)
// recordingChannel is a fake AgentChannel that records tool invocations.
type recordingChannel struct {
- asked string
- summary string
- spawned []SubtaskSpec
- progress []string
- spawnID string
- proposedEpics []EpicProposal
- epicID string
+ asked string
+ summary string
+ spawned []SubtaskSpec
+ progress []string
+ spawnID string
+ proposedEpics []EpicProposal
+ epicID string
+ proposedRoleConfigs []role.RoleConfig
+ roleConfigVersion int
}
func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) {
@@ -41,6 +44,10 @@ func (c *recordingChannel) ProposeEpic(_ context.Context, spec EpicProposal) (st
c.proposedEpics = append(c.proposedEpics, spec)
return c.epicID, nil
}
+func (c *recordingChannel) ProposeRoleConfig(_ context.Context, cfg role.RoleConfig) (int, error) {
+ c.proposedRoleConfigs = append(c.proposedRoleConfigs, cfg)
+ return c.roleConfigVersion, nil
+}
func resultText(t *testing.T, res *mcp.CallToolResult) string {
t.Helper()
@@ -209,6 +216,45 @@ func TestAgentServer_ProposeEpic(t *testing.T) {
}
}
+// TestAgentServer_ProposeRoleConfig proves the propose_role_config MCP tool
+// reaches AgentChannel.ProposeRoleConfig with the full role.RoleConfig shape
+// decoded correctly, including a nested escalation_ladder tier -- mirroring
+// TestAgentServer_ProposeEpic's coverage above for this phase's new tool.
+func TestAgentServer_ProposeRoleConfig(t *testing.T) {
+ ch := &recordingChannel{roleConfigVersion: 3}
+ cs := connectInMemory(t, newAgentServer(ch))
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "propose_role_config",
+ Arguments: map[string]any{
+ "role": "builder",
+ "system_prompt": "Double-check edge cases before finishing.",
+ "escalation_ladder": []map[string]any{
+ {
+ "candidates": []map[string]any{{"provider": "anthropic", "model": "claude-sonnet-4-6"}},
+ "max_retries": 1,
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if len(ch.proposedRoleConfigs) != 1 {
+ t.Fatalf("expected 1 proposed role config, got %d", len(ch.proposedRoleConfigs))
+ }
+ cfg := ch.proposedRoleConfigs[0]
+ if cfg.Role != "builder" || cfg.SystemPrompt != "Double-check edge cases before finishing." {
+ t.Errorf("role config not propagated: %+v", cfg)
+ }
+ if len(cfg.EscalationLadder) != 1 || len(cfg.EscalationLadder[0].Candidates) != 1 ||
+ cfg.EscalationLadder[0].Candidates[0].Provider != "anthropic" || cfg.EscalationLadder[0].Candidates[0].Model != "claude-sonnet-4-6" {
+ t.Errorf("escalation_ladder not propagated: %+v", cfg.EscalationLadder)
+ }
+ if txt := resultText(t, res); !strings.Contains(txt, "builder") || !strings.Contains(txt, "3") {
+ t.Errorf("expected role name and version 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 ef64ede..5afe22e 100644
--- a/internal/executor/channel.go
+++ b/internal/executor/channel.go
@@ -10,6 +10,8 @@ import (
"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"
"github.com/google/uuid"
@@ -50,6 +52,12 @@ type channelStore interface {
GetEpicByName(name string) (*story.Epic, error)
GetStory(id string) (*story.Story, error)
UpdateStory(st *story.Story) error
+ // CreateRoleConfig backs storeChannel.ProposeRoleConfig (Phase 8): it
+ // inserts a new "draft"-status role_configs row for a role, auto-
+ // assigning the next version number, exactly the way
+ // api.handleCreateRoleVersion (the human-facing
+ // POST /api/roles/{role}/versions endpoint) already uses it.
+ CreateRoleConfig(roleName, configJSON, proposedBy string) (*storage.RoleConfigRow, error)
}
// pendingAsker is implemented by channels that buffer an ask_user call so the
@@ -223,4 +231,45 @@ func (c *storeChannel) ProposeEpic(_ context.Context, spec agentchannel.EpicProp
return epic.ID, err
}
return epic.ID, nil
+}
+
+// ProposeRoleConfig creates a new draft role_configs row for config.Role,
+// proposed_by "retro" (the only role this phase wires the tool up for,
+// though nothing else about the mechanism is retro-specific). It never reads
+// or touches whatever version is currently active for that role — draft
+// creation and activation are deliberately separate paths (see
+// storage.CreateRoleConfig / storage.ActivateRoleConfigVersion), the latter
+// staying a human action via POST /api/roles/{role}/activate.
+//
+// Records event.KindRoleConfigProposed on the *calling task's own ID*
+// (c.taskID, not a role or story ID — roles have no first-class row/ID of
+// their own the way epics/stories do), payload {role, version}. This mirrors
+// ProposeEpic's "one event per proposal call" convention but attaches to the
+// task rather than the proposed entity: internal/scheduler.StoryOrchestrator
+// reads a retro task's own event stream for these once the task completes,
+// to assemble the aggregate KindRetroCaptured payload it attaches to the
+// story's ID (see that package's finalizeRetro).
+func (c *storeChannel) ProposeRoleConfig(_ context.Context, config role.RoleConfig) (int, error) {
+ configJSON, err := json.Marshal(config)
+ if err != nil {
+ return 0, err
+ }
+ row, err := c.store.CreateRoleConfig(config.Role, string(configJSON), "retro")
+ if err != nil {
+ return 0, err
+ }
+
+ payload, _ := json.Marshal(struct {
+ Role string `json:"role"`
+ Version int `json:"version"`
+ }{Role: row.Role, Version: row.Version})
+ if err := c.store.CreateEvent(&event.Event{
+ TaskID: c.taskID,
+ Kind: event.KindRoleConfigProposed,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }); err != nil {
+ return row.Version, err
+ }
+ return row.Version, nil
} \ No newline at end of file
diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go
index e05228a..522f23a 100644
--- a/internal/executor/channel_test.go
+++ b/internal/executor/channel_test.go
@@ -9,6 +9,8 @@ import (
"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"
)
@@ -26,6 +28,13 @@ type fakeChannelStore struct {
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 {
@@ -81,6 +90,26 @@ func (f *fakeChannelStore) UpdateStory(st *story.Story) error {
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"}`)
@@ -410,3 +439,102 @@ func TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent(t *testing.T) {
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)
+ }
+}
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index 84b3208..6261cd8 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
+ "github.com/thepeterstone/claudomator/internal/role"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -835,3 +836,6 @@ func (noopChannel) RecordProgress(_ context.Context, _ string) error { return ni
func (noopChannel) ProposeEpic(_ context.Context, _ EpicProposal) (string, error) {
return "", nil
}
+func (noopChannel) ProposeRoleConfig(_ context.Context, _ role.RoleConfig) (int, error) {
+ return 0, nil
+}
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 3c41250..e2eb7ce 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -55,6 +55,9 @@ type Store interface {
GetEpicByName(name string) (*story.Epic, error)
GetStory(id string) (*story.Story, error)
UpdateStory(st *story.Story) error
+ // CreateRoleConfig backs storeChannel.ProposeRoleConfig (Phase 8) — see
+ // internal/executor/channel.go and internal/storage/roleconfig.go.
+ CreateRoleConfig(roleName, configJSON, proposedBy string) (*storage.RoleConfigRow, 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 7a7da48..12d052c 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -1174,6 +1174,9 @@ func (m *minimalMockStore) GetEpicByName(_ string) (*story.Epic, error) {
}
func (m *minimalMockStore) GetStory(_ string) (*story.Story, error) { return nil, sql.ErrNoRows }
func (m *minimalMockStore) UpdateStory(_ *story.Story) error { return nil }
+func (m *minimalMockStore) CreateRoleConfig(roleName, configJSON, proposedBy string) (*storage.RoleConfigRow, error) {
+ return &storage.RoleConfigRow{Role: roleName, Version: 1, Status: "draft", ConfigJSON: configJSON, ProposedBy: proposedBy}, 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 afdb49d..89a7ab5 100644
--- a/internal/executor/nativerunner_test.go
+++ b/internal/executor/nativerunner_test.go
@@ -282,6 +282,65 @@ func TestNativeRunner_Run_ToolLoop_ProposeEpic(t *testing.T) {
}
}
+// TestNativeRunner_Run_ToolLoop_ProposeRoleConfig is an end-to-end-ish test
+// (fake LLM server, real agentloop.Loop/tools.go dispatch, real
+// storeChannel) proving a propose_role_config tool call reaches
+// AgentChannel.ProposeRoleConfig correctly through
+// internal/agentloop/tools.go's dispatchTool, and from there through
+// storeChannel.ProposeRoleConfig into a new draft role_configs row —
+// mirroring the propose_epic coverage above (Phase 7c) for this phase's
+// (Phase 8) new tool.
+func TestNativeRunner_Run_ToolLoop_ProposeRoleConfig(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "propose_role_config", `{"role":"builder","system_prompt":"Be more careful.","escalation_ladder":[{"candidates":[{"provider":"anthropic","model":"claude-sonnet-4-6"}],"max_retries":1}]}`)}},
+ {content: "finished"},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ store := &fakeChannelStore{}
+ 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)
+ }
+
+ rows := store.roleConfigs["builder"]
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 created role_configs row, got %d", len(rows))
+ }
+ row := rows[0]
+ if row.Status != "draft" {
+ t.Errorf("expected status draft, got %q", row.Status)
+ }
+ var decoded struct {
+ Role string `json:"role"`
+ SystemPrompt string `json:"system_prompt"`
+ EscalationLadder []struct {
+ Candidates []struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ } `json:"candidates"`
+ MaxRetries int `json:"max_retries"`
+ } `json:"escalation_ladder"`
+ }
+ 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." {
+ t.Errorf("config fields not propagated: %+v", decoded)
+ }
+ if len(decoded.EscalationLadder) != 1 || len(decoded.EscalationLadder[0].Candidates) != 1 ||
+ decoded.EscalationLadder[0].Candidates[0].Provider != "anthropic" {
+ t.Errorf("escalation_ladder not propagated: %+v", decoded.EscalationLadder)
+ }
+ if len(store.createdEvents) != 1 || store.createdEvents[0].TaskID != tt.ID {
+ t.Errorf("expected 1 role_config_proposed event attached to the calling task, got %+v", store.createdEvents)
+ }
+}
+
func TestNativeRunner_Run_RecordProgress(t *testing.T) {
srv := fakeChatServer(t, []fakeTurn{
{toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}},