summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /internal
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/api/agentmcp_endpoint_test.go104
-rw-r--r--internal/api/budget.go36
-rw-r--r--internal/api/budget_test.go68
-rw-r--r--internal/api/chatbotmcp.go218
-rw-r--r--internal/api/chatbotmcp_test.go233
-rw-r--r--internal/api/elaborate.go337
-rw-r--r--internal/api/elaborate_local_test.go214
-rw-r--r--internal/api/elaborate_test.go527
-rw-r--r--internal/api/events.go38
-rw-r--r--internal/api/events_test.go94
-rw-r--r--internal/api/server.go141
-rw-r--r--internal/api/server_test.go34
-rw-r--r--internal/api/taskops.go253
-rw-r--r--internal/api/validate_test.go21
-rw-r--r--internal/budget/budget.go122
-rw-r--r--internal/budget/budget_test.go112
-rw-r--r--internal/cli/serve.go43
-rw-r--r--internal/config/bind.go44
-rw-r--r--internal/config/bind_test.go27
-rw-r--r--internal/config/config.go13
-rw-r--r--internal/event/event.go50
-rw-r--r--internal/event/event_test.go42
-rw-r--r--internal/executor/agentmcp.go160
-rw-r--r--internal/executor/agentmcp_test.go218
-rw-r--r--internal/executor/channel.go159
-rw-r--r--internal/executor/channel_test.go124
-rw-r--r--internal/executor/claude.go554
-rw-r--r--internal/executor/claude_test.go810
-rw-r--r--internal/executor/container.go147
-rw-r--r--internal/executor/container_test.go177
-rw-r--r--internal/executor/executor.go67
-rw-r--r--internal/executor/executor_test.go65
-rw-r--r--internal/executor/gemini.go346
-rw-r--r--internal/executor/gemini_test.go447
-rw-r--r--internal/executor/helpers.go26
-rw-r--r--internal/executor/local.go114
-rw-r--r--internal/executor/local_test.go197
-rw-r--r--internal/executor/localtools.go135
-rw-r--r--internal/executor/preamble.go67
-rw-r--r--internal/executor/preamble_test.go30
-rw-r--r--internal/llm/client.go37
-rw-r--r--internal/llm/client_test.go40
-rw-r--r--internal/storage/db.go219
-rw-r--r--internal/storage/db_test.go38
-rw-r--r--internal/storage/events.go120
-rw-r--r--internal/storage/events_test.go456
-rw-r--r--internal/task/task.go2
47 files changed, 3851 insertions, 3675 deletions
diff --git a/internal/api/agentmcp_endpoint_test.go b/internal/api/agentmcp_endpoint_test.go
new file mode 100644
index 0000000..b2f77d3
--- /dev/null
+++ b/internal/api/agentmcp_endpoint_test.go
@@ -0,0 +1,104 @@
+package api
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/executor"
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+type fakeAgentChannel struct{ progress []string }
+
+func (f *fakeAgentChannel) AskUser(context.Context, string) (string, error) {
+ return "", executor.ErrAgentBlocked
+}
+func (f *fakeAgentChannel) ReportSummary(context.Context, string) error { return nil }
+func (f *fakeAgentChannel) SpawnSubtask(context.Context, executor.SubtaskSpec) (string, error) {
+ return "sub", nil
+}
+func (f *fakeAgentChannel) RecordProgress(_ context.Context, m string) error {
+ f.progress = append(f.progress, m)
+ return nil
+}
+
+type tokenRT struct {
+ token string
+ base http.RoundTripper
+}
+
+func (b tokenRT) RoundTrip(r *http.Request) (*http.Response, error) {
+ r = r.Clone(r.Context())
+ r.Header.Set("Authorization", "Bearer "+b.token)
+ return b.base.RoundTrip(r)
+}
+
+func TestServer_MountsAgentMCPEndpoint(t *testing.T) {
+ store, err := storage.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger)
+
+ reg := executor.NewRegistry()
+ fc := &fakeAgentChannel{}
+ token, _ := reg.Mint(fc)
+
+ srv := NewServer(store, pool, reg, logger, "claude", "gemini")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("connect to mounted /mcp: %v", err)
+ }
+ defer cs.Close()
+
+ if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "record_progress",
+ Arguments: map[string]any{"message": "via api server"},
+ }); err != nil {
+ t.Fatalf("CallTool through api server: %v", err)
+ }
+ if len(fc.progress) != 1 || fc.progress[0] != "via api server" {
+ t.Errorf("tool call did not reach channel: %+v", fc.progress)
+ }
+}
+
+func TestServer_NoRegistry_NoMCPRoute(t *testing.T) {
+ store, err := storage.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger)
+
+ // nil registry → /mcp must not be mounted.
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ resp, err := http.Post(ts.URL+"/mcp", "application/json", http.NoBody)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ // Unmounted: the catch-all "GET /" route rejects POST with 405. A mounted
+ // MCP handler would instead respond (e.g. 400 for the missing token).
+ if resp.StatusCode == http.StatusBadRequest {
+ t.Errorf("expected /mcp to be absent without a registry, got 400 (handler ran)")
+ }
+}
diff --git a/internal/api/budget.go b/internal/api/budget.go
new file mode 100644
index 0000000..eee5d09
--- /dev/null
+++ b/internal/api/budget.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+// budgetReporter exposes per-provider spend headroom. Satisfied by
+// *budget.Accountant; an interface keeps the handler testable.
+type budgetReporter interface {
+ All() ([]budget.Headroom, error)
+}
+
+// SetBudget wires the budget accountant so GET /api/budget can report headroom.
+func (s *Server) SetBudget(b budgetReporter) {
+ s.budget = b
+}
+
+// handleGetBudget returns per-provider rolling-window spend headroom. When no
+// budget is configured it returns an empty list so the UI simply shows nothing.
+func (s *Server) handleGetBudget(w http.ResponseWriter, _ *http.Request) {
+ if s.budget == nil {
+ writeJSON(w, http.StatusOK, []budget.Headroom{})
+ return
+ }
+ hs, err := s.budget.All()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if hs == nil {
+ hs = []budget.Headroom{}
+ }
+ writeJSON(w, http.StatusOK, hs)
+}
diff --git a/internal/api/budget_test.go b/internal/api/budget_test.go
new file mode 100644
index 0000000..d59836d
--- /dev/null
+++ b/internal/api/budget_test.go
@@ -0,0 +1,68 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+type fakeBudget struct {
+ hs []budget.Headroom
+ err error
+}
+
+func (f fakeBudget) All() ([]budget.Headroom, error) { return f.hs, f.err }
+
+func TestHandleGetBudget_NoBudgetConfigured_ReturnsEmpty(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 0 {
+ t.Errorf("want empty list, got %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ReportsHeadroom(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{hs: []budget.Headroom{
+ {Provider: "claude", Limited: true, Limit: 10, Spent: 4, Remaining: 6, Fraction: 0.6},
+ }})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 1 || hs[0].Provider != "claude" || hs[0].Remaining != 6 {
+ t.Errorf("unexpected headroom: %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ErrorReturns500(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{err: errors.New("db down")})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("want 500, got %d", w.Code)
+ }
+}
diff --git a/internal/api/chatbotmcp.go b/internal/api/chatbotmcp.go
new file mode 100644
index 0000000..e14b522
--- /dev/null
+++ b/internal/api/chatbotmcp.go
@@ -0,0 +1,218 @@
+package api
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// The chatbot-facing MCP server lets a chatbot drive task creation and
+// orchestration. Unlike the per-task agent server, it spans all tasks and is
+// guarded by the single shared API bearer token (config api_token / SetAPIToken).
+
+type submitTaskMCPInput struct {
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the agent that will run this task"`
+ Name string `json:"name,omitempty" jsonschema:"short human-readable task name; defaults to the first line of instructions"`
+ Project string `json:"project,omitempty" jsonschema:"name of a configured project; used to resolve the repository when repository_url is omitted"`
+ RepositoryURL string `json:"repository_url,omitempty" jsonschema:"git repository URL to work in; required unless a project resolves to one"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional spend cap in USD"`
+ Timeout string `json:"timeout,omitempty" jsonschema:"optional duration like 15m or 1h"`
+ Tags []string `json:"tags,omitempty" jsonschema:"optional tags"`
+}
+
+type listTasksMCPInput struct {
+ State string `json:"state,omitempty" jsonschema:"optional state filter, e.g. RUNNING, READY, BLOCKED, COMPLETED, FAILED"`
+ Limit int `json:"limit,omitempty" jsonschema:"optional max number of tasks to return"`
+}
+
+type taskIDInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+}
+
+type getEventsMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+ SinceSeq int64 `json:"since_seq,omitempty" jsonschema:"return only events with seq greater than this value"`
+}
+
+type answerQuestionMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID of the BLOCKED task"`
+ Answer string `json:"answer" jsonschema:"the answer to the agent's question"`
+}
+
+type rejectTaskMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+ Comment string `json:"comment,omitempty" jsonschema:"optional reason the work was rejected, fed back to the agent on retry"`
+}
+
+type compactTask struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ State task.State `json:"state"`
+ Project string `json:"project,omitempty"`
+}
+
+func mcpText(text string) *mcp.CallToolResult {
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
+}
+
+func mcpJSON(v any) (*mcp.CallToolResult, any, error) {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpText(string(b)), nil, nil
+}
+
+// newChatbotServer builds the chatbot-facing MCP server bound to this Server's
+// store and pool.
+func (s *Server) newChatbotServer() *mcp.Server {
+ srv := mcp.NewServer(&mcp.Implementation{Name: "claudomator-chatbot", Version: "1"}, nil)
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "submit_task",
+ Description: "Create a task and immediately queue it for execution. Returns the new task ID and state.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in submitTaskMCPInput) (*mcp.CallToolResult, any, error) {
+ spec := submitTaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Project: in.Project,
+ RepositoryURL: in.RepositoryURL,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ Tags: in.Tags,
+ }
+ if strings.TrimSpace(in.Timeout) != "" {
+ d, err := time.ParseDuration(in.Timeout)
+ if err != nil {
+ return nil, nil, badRequestf("invalid timeout %q: %v", in.Timeout, err)
+ }
+ spec.Timeout = d
+ }
+ t, err := s.submitTask(ctx, spec)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpJSON(compactTask{ID: t.ID, Name: t.Name, State: t.State, Project: t.Project})
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "list_tasks",
+ Description: "List tasks, optionally filtered by state. Returns compact records; use get_task for full detail.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in listTasksMCPInput) (*mcp.CallToolResult, any, error) {
+ filter := storage.TaskFilter{Limit: in.Limit}
+ if in.State != "" {
+ st := task.State(in.State)
+ if !validTaskStates[st] {
+ return nil, nil, badRequestf("invalid state %q", in.State)
+ }
+ filter.State = st
+ }
+ tasks, err := s.store.ListTasks(filter)
+ if err != nil {
+ return nil, nil, err
+ }
+ out := make([]compactTask, 0, len(tasks))
+ for _, tk := range tasks {
+ out = append(out, compactTask{ID: tk.ID, Name: tk.Name, State: tk.State, Project: tk.Project})
+ }
+ return mcpJSON(out)
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "get_task",
+ Description: "Get one task with enriched detail (changestats, deployment status, error message).",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ tk, err := s.store.GetTask(in.TaskID)
+ if err != nil {
+ return nil, nil, errTaskNotFound
+ }
+ return mcpJSON(s.enrichTask(tk))
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "get_events",
+ Description: "Get the observability event stream for a task in seq order. Pass since_seq to page incrementally.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in getEventsMCPInput) (*mcp.CallToolResult, any, error) {
+ if _, err := s.store.GetTask(in.TaskID); err != nil {
+ return nil, nil, errTaskNotFound
+ }
+ events, err := s.store.ListEvents(in.TaskID, in.SinceSeq)
+ if err != nil {
+ return nil, nil, err
+ }
+ if events == nil {
+ events = []*event.Event{}
+ }
+ return mcpJSON(events)
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "answer_question",
+ Description: "Answer a BLOCKED task's clarification question. The task resumes from where the agent left off.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in answerQuestionMCPInput) (*mcp.CallToolResult, any, error) {
+ if err := s.answerTaskQuestion(ctx, in.TaskID, in.Answer); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Answer recorded; task queued for resume."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "accept_task",
+ Description: "Accept a READY task, marking it COMPLETED.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ if err := s.acceptTask(ctx, in.TaskID, event.ActorChatbot); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Task accepted."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "reject_task",
+ Description: "Reject a READY task, sending it back to PENDING with an optional comment for the next attempt.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in rejectTaskMCPInput) (*mcp.CallToolResult, any, error) {
+ if err := s.rejectTask(in.TaskID, in.Comment); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Task rejected."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "cancel_task",
+ Description: "Cancel a RUNNING or QUEUED task.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ msg, err := s.cancelTask(in.TaskID, event.ActorChatbot)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpText(msg), nil, nil
+ })
+
+ return srv
+}
+
+// chatbotMCPHandler returns the HTTP handler for the chatbot MCP server. It
+// serves only when an API token is configured and the request presents it as a
+// bearer credential; otherwise the underlying handler receives a nil server and
+// rejects the request.
+func (s *Server) chatbotMCPHandler() http.Handler {
+ srv := s.newChatbotServer()
+ return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
+ if s.apiToken == "" {
+ return nil
+ }
+ tok := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
+ if subtle.ConstantTimeCompare([]byte(tok), []byte(s.apiToken)) != 1 {
+ return nil
+ }
+ return srv
+ }, nil)
+}
diff --git a/internal/api/chatbotmcp_test.go b/internal/api/chatbotmcp_test.go
new file mode 100644
index 0000000..d13b0d7
--- /dev/null
+++ b/internal/api/chatbotmcp_test.go
@@ -0,0 +1,233 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// chatbotClient starts the api server with the given token and returns a
+// connected MCP client session pointed at /chatbot/mcp.
+func chatbotClient(t *testing.T, srv *Server, token string) *mcp.ClientSession {
+ t.Helper()
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("connect to /chatbot/mcp: %v", err)
+ }
+ t.Cleanup(func() { cs.Close() })
+ return cs
+}
+
+func callText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string {
+ t.Helper()
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
+ if err != nil {
+ t.Fatalf("CallTool %s transport error: %v", name, err)
+ }
+ if res.IsError {
+ t.Fatalf("CallTool %s returned tool error: %s", name, toolText(res))
+ }
+ return toolText(res)
+}
+
+func toolText(res *mcp.CallToolResult) string {
+ if len(res.Content) == 0 {
+ return ""
+ }
+ if tc, ok := res.Content[0].(*mcp.TextContent); ok {
+ return tc.Text
+ }
+ return ""
+}
+
+func TestChatbotMCP_RequiresToken(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: "wrong", base: http.DefaultTransport}},
+ }, nil)
+ if err == nil {
+ t.Fatal("expected connect with wrong token to be rejected")
+ }
+}
+
+func TestChatbotMCP_SubmitTask_CreatesAndQueues(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "submit_task", map[string]any{
+ "instructions": "fix the flaky test in package foo",
+ "repository_url": "https://github.com/user/repo",
+ })
+ var ct compactTask
+ if err := json.Unmarshal([]byte(out), &ct); err != nil {
+ t.Fatalf("unmarshal submit_task result %q: %v", out, err)
+ }
+ if ct.ID == "" {
+ t.Fatal("submit_task returned empty task ID")
+ }
+ if ct.Name != "fix the flaky test in package foo" {
+ t.Errorf("name defaulted wrong: %q", ct.Name)
+ }
+ if _, err := store.GetTask(ct.ID); err != nil {
+ t.Fatalf("submitted task not persisted: %v", err)
+ }
+}
+
+func TestChatbotMCP_SubmitTask_RequiresRepo(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "submit_task",
+ Arguments: map[string]any{"instructions": "do a thing"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected submit_task without repository_url to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetAndListTasks(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-ready", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ got := callText(t, cs, "get_task", map[string]any{"task_id": "ct-ready"})
+ var view map[string]any
+ if err := json.Unmarshal([]byte(got), &view); err != nil {
+ t.Fatalf("unmarshal get_task: %v", err)
+ }
+ if view["id"] != "ct-ready" {
+ t.Errorf("get_task id: got %v", view["id"])
+ }
+
+ listed := callText(t, cs, "list_tasks", map[string]any{"state": "READY"})
+ var tasks []compactTask
+ if err := json.Unmarshal([]byte(listed), &tasks); err != nil {
+ t.Fatalf("unmarshal list_tasks: %v", err)
+ }
+ if len(tasks) != 1 || tasks[0].ID != "ct-ready" {
+ t.Errorf("list_tasks returned %+v", tasks)
+ }
+}
+
+func TestChatbotMCP_GetTask_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "get_task",
+ Arguments: map[string]any{"task_id": "nope"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected get_task on missing task to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetEvents(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-events", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "get_events", map[string]any{"task_id": "ct-events"})
+ var events []map[string]any
+ if err := json.Unmarshal([]byte(out), &events); err != nil {
+ t.Fatalf("unmarshal get_events %q: %v", out, err)
+ }
+ // State walk to READY emits state_change events.
+ if len(events) == 0 {
+ t.Error("expected at least one event for a task that walked to READY")
+ }
+}
+
+func TestChatbotMCP_AcceptTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-accept", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "accept_task", map[string]any{"task_id": "ct-accept"})
+ got, _ := store.GetTask("ct-accept")
+ if got.State != task.StateCompleted {
+ t.Errorf("after accept_task: want COMPLETED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_RejectTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-reject", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "reject_task", map[string]any{"task_id": "ct-reject", "comment": "needs work"})
+ got, _ := store.GetTask("ct-reject")
+ if got.State != task.StatePending {
+ t.Errorf("after reject_task: want PENDING, got %s", got.State)
+ }
+ if got.RejectionComment != "needs work" {
+ t.Errorf("rejection comment: got %q", got.RejectionComment)
+ }
+}
+
+func TestChatbotMCP_CancelTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-cancel", task.StateQueued)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "cancel_task", map[string]any{"task_id": "ct-cancel"})
+ got, _ := store.GetTask("ct-cancel")
+ if got.State != task.StateCancelled {
+ t.Errorf("after cancel_task: want CANCELLED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_AnswerQuestion(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-answer", task.StateBlocked)
+ if err := store.CreateExecution(&storage.Execution{
+ ID: "ct-answer-exec",
+ TaskID: "ct-answer",
+ SessionID: "550e8400-e29b-41d4-a716-446655440099",
+ Status: "BLOCKED",
+ }); err != nil {
+ t.Fatalf("create execution: %v", err)
+ }
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "answer_question", map[string]any{"task_id": "ct-answer", "answer": "use main"})
+ got, _ := store.GetTask("ct-answer")
+ if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady {
+ t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State)
+ }
+}
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 8676b36..e8930a6 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -6,128 +6,13 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
"os/exec"
- "path/filepath"
- "sort"
"strings"
"time"
-
- "github.com/thepeterstone/claudomator/internal/llm"
)
const elaborateTimeout = 30 * time.Second
-func buildElaboratePrompt(workDir string) string {
- workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,`
- if workDir != "" {
- workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir)
- }
- return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess.
-
-Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON.
-
-Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation):
-
-{
- "name": string — short imperative title (≤60 chars),
- "description": string — 1-2 sentence summary,
- "agent": {
- "type": "claude" | "gemini",
- "model": string — "sonnet" for claude, "gemini-2.5-flash-lite" for gemini,
- "instructions": string — detailed, step-by-step instructions for the agent. Must end with a "## Acceptance Criteria" section listing measurable conditions that define success. For coding tasks, include TDD requirements (write failing tests first, then implement),
-` + workDirLine + `
- "max_budget_usd": number — conservative estimate (0.25–5.00),
- "allowed_tools": array — every tool the task genuinely needs. Include "Write" if creating files, "Edit" if modifying files, "Read" if reading files, "Bash" for shell/git/test commands, "Grep"/"Glob" for searching.
- },
- "timeout": string — e.g. "15m",
- "priority": string — "normal" | "high" | "low",
- "tags": array — relevant lowercase tags
-}`
-}
-
-// elaboratedTask mirrors the task creation schema for elaboration responses.
-type elaboratedTask struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Agent elaboratedAgent `json:"agent"`
- Timeout string `json:"timeout"`
- Priority string `json:"priority"`
- Tags []string `json:"tags"`
-}
-
-type elaboratedAgent struct {
- Type string `json:"type"`
- Model string `json:"model"`
- Instructions string `json:"instructions"`
- ProjectDir string `json:"project_dir"`
- MaxBudgetUSD float64 `json:"max_budget_usd"`
- AllowedTools []string `json:"allowed_tools"`
-}
-
-// sanitizeElaboratedTask enforces tool completeness and dev practice compliance.
-// It modifies t in place, inferring missing tools from instruction keywords and
-// appending required sections when they are absent.
-func sanitizeElaboratedTask(t *elaboratedTask) {
- lower := strings.ToLower(t.Agent.Instructions)
-
- // Build current tool set.
- toolSet := make(map[string]bool, len(t.Agent.AllowedTools))
- for _, tool := range t.Agent.AllowedTools {
- toolSet[tool] = true
- }
-
- // Infer missing tools from instruction keywords.
- type rule struct {
- tool string
- keywords []string
- }
- rules := []rule{
- {"Write", []string{"create file", "write file", "new file", "write to", "save to", "output to", "generate file", "creates a file", "create a new file"}},
- {"Edit", []string{"edit", "modify", "refactor", "replace", "patch"}},
- {"Read", []string{"read", "inspect", "examine", "look at the file"}},
- {"Bash", []string{"run", "execute", "bash", "shell", "command", "build", "compile", "git", "install", "make"}},
- {"Grep", []string{"search for", "grep", "find in", "locate in"}},
- {"Glob", []string{"find file", "list file", "search file"}},
- }
- for _, r := range rules {
- if toolSet[r.tool] {
- continue
- }
- for _, kw := range r.keywords {
- if strings.Contains(lower, kw) {
- toolSet[r.tool] = true
- break
- }
- }
- }
- // Edit without Read is almost always wrong.
- if toolSet["Edit"] && !toolSet["Read"] {
- toolSet["Read"] = true
- }
- // Rebuild the list only when tools were added.
- if len(toolSet) > len(t.Agent.AllowedTools) {
- tools := make([]string, 0, len(toolSet))
- for tool := range toolSet {
- tools = append(tools, tool)
- }
- sort.Strings(tools)
- t.Agent.AllowedTools = tools
- }
-
- // Append an acceptance criteria section when none is present.
- if !strings.Contains(lower, "acceptance") &&
- !strings.Contains(lower, "done when") &&
- !strings.Contains(lower, "success criteria") {
- t.Agent.Instructions += "\n\n## Acceptance Criteria\nBefore finishing, verify all stated goals are met, tests pass (if applicable), and no unintended side effects were introduced."
- }
-
- // Append a TDD reminder for coding tasks that do not already mention tests.
- if (toolSet["Edit"] || toolSet["Write"]) && !strings.Contains(lower, "test") {
- t.Agent.Instructions += "\n\n## Dev Practices\nFollow TDD: write a failing test first, then implement the minimum code to make it pass. Commit all changes before finishing."
- }
-}
-
// claudeJSONResult is the top-level object returned by `claude --output-format json`.
type claudeJSONResult struct {
Result string `json:"result"`
@@ -167,142 +52,6 @@ func (s *Server) geminiBinaryPath() string {
return "gemini"
}
-func readProjectContext(workDir string) string {
- if workDir == "" {
- return ""
- }
- var sb strings.Builder
- for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} {
- path := filepath.Join(workDir, filename)
- if data, err := os.ReadFile(path); err == nil {
- if sb.Len() > 0 {
- sb.WriteString("\n\n")
- }
- sb.WriteString(fmt.Sprintf("--- %s ---\n%s", filename, string(data)))
- }
- }
- return sb.String()
-}
-
-func (s *Server) appendRawNarrative(workDir, prompt string) {
- if workDir == "" {
- return
- }
- docsDir := filepath.Join(workDir, "docs")
- if err := os.MkdirAll(docsDir, 0755); err != nil {
- s.logger.Error("elaborate: failed to create docs directory", "error", err, "path", docsDir)
- return
- }
- path := filepath.Join(docsDir, "RAW_NARRATIVE.md")
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- s.logger.Error("elaborate: failed to open RAW_NARRATIVE.md", "error", err, "path", path)
- return
- }
- defer f.Close()
-
- entry := fmt.Sprintf("\n--- %s ---\n%s\n", time.Now().Format(time.RFC3339), prompt)
- if _, err := f.WriteString(entry); err != nil {
- s.logger.Error("elaborate: failed to write to RAW_NARRATIVE.md", "error", err, "path", path)
- }
-}
-
-func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- cmd := exec.CommandContext(ctx, s.claudeBinaryPath(),
- "-p", fullPrompt,
- "--system-prompt", buildElaboratePrompt(workDir),
- "--output-format", "json",
- "--model", "haiku",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- // Claude returns exit 1 if rate limited but still outputs JSON with is_error: true
- err := cmd.Run()
-
- output := stdout.Bytes()
- if len(output) == 0 {
- if err != nil {
- return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String())
- }
- return nil, fmt.Errorf("claude returned no output")
- }
-
- var wrapper claudeJSONResult
- if jerr := json.Unmarshal(output, &wrapper); jerr != nil {
- return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output))
- }
-
- if wrapper.IsError {
- return nil, fmt.Errorf("claude error: %s", wrapper.Result)
- }
-
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (result: %s)", jerr, wrapper.Result)
- }
-
- return &result, nil
-}
-
-// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM.
-// It uses the same prompt template as the Claude/Gemini paths and requests
-// json_object response format so we can decode directly without the
-// markdown-fence cleanup needed for the CLI paths.
-func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) {
- if c == nil {
- return nil, fmt.Errorf("local llm: no client configured")
- }
- systemPrompt := buildElaboratePrompt(workDir)
- resp, err := c.Chat(ctx, llm.ChatRequest{
- Messages: []llm.Message{
- {Role: "system", Content: systemPrompt},
- {Role: "user", Content: fullPrompt},
- },
- ResponseJSON: true,
- })
- if err != nil {
- return nil, fmt.Errorf("local llm: %w", err)
- }
- body := strings.TrimSpace(resp.Content)
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil {
- return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body)
- }
- return &result, nil
-}
-
-func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt)
- cmd := exec.CommandContext(ctx, s.geminiBinaryPath(),
- "-p", combinedPrompt,
- "--output-format", "json",
- "--model", "gemini-2.5-flash-lite",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- if err := cmd.Run(); err != nil {
- return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String())
- }
-
- var wrapper geminiJSONResult
- if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil {
- return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String())
- }
-
- var result elaboratedTask
- if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (response: %s)", err, wrapper.Response)
- }
-
- return &result, nil
-}
-
// elaboratedStorySubtask is a leaf unit within a story task.
type elaboratedStorySubtask struct {
Name string `json:"name"`
@@ -493,89 +242,3 @@ func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
-
-func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
- if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) {
- writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
- return
- }
-
- var input struct {
- Prompt string `json:"prompt"`
- ProjectID string `json:"project_id"`
- // project_dir kept for backward compat; project_id takes precedence
- ProjectDir string `json:"project_dir"`
- }
- if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
- return
- }
- if input.Prompt == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
- return
- }
-
- workDir := s.workDir
- if input.ProjectID != "" {
- if proj, err := s.store.GetProject(input.ProjectID); err == nil {
- workDir = proj.LocalPath
- }
- } else if input.ProjectDir != "" {
- workDir = input.ProjectDir
- }
-
- if workDir != s.workDir {
- go s.appendRawNarrative(workDir, input.Prompt)
- }
-
- projectContext := readProjectContext(workDir)
- fullPrompt := input.Prompt
- if projectContext != "" {
- fullPrompt = fmt.Sprintf("Project context from %s:\n%s\n\nUser request: %s", workDir, projectContext, input.Prompt)
- }
-
- ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
- defer cancel()
-
- var result *elaboratedTask
- var err error
-
- // Try local LLM first when configured. Falls back to Claude → Gemini on
- // hard failure of each prior attempt.
- if s.llm != nil {
- result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err)
- result = nil
- }
- }
- if result == nil {
- result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err)
- result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Error("elaborate: gemini also failed", "error", err)
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": fmt.Sprintf("elaboration failed: %v", err),
- })
- return
- }
- }
- }
-
- if result.Name == "" || result.Agent.Instructions == "" {
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": "elaboration failed: missing required fields in response",
- })
- return
- }
-
- if result.Agent.Type == "" {
- result.Agent.Type = "claude"
- }
-
- sanitizeElaboratedTask(result)
-
- writeJSON(w, http.StatusOK, result)
-}
diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go
deleted file mode 100644
index 09a8f9e..0000000
--- a/internal/api/elaborate_local_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package api
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-
- "github.com/thepeterstone/claudomator/internal/llm"
-)
-
-// fakeChatCompletionsServer returns an httptest server that responds to a
-// /chat/completions POST with the given assistant content (which should be a
-// JSON-encoded elaboratedTask). Returns the server and a counter of calls
-// received so tests can assert dispatch ordering.
-func fakeChatCompletionsServer(t *testing.T, assistantContent string) (*httptest.Server, *int32) {
- t.Helper()
- var calls int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.Header().Set("Content-Type", "application/json")
- // The assistant content has to be JSON-encoded inside the wire format.
- escaped, _ := json.Marshal(assistantContent)
- fmt.Fprintf(w, `{
- "model":"local",
- "choices":[{"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],
- "usage":{"prompt_tokens":10,"completion_tokens":50}
- }`, string(escaped))
- }))
- t.Cleanup(srv.Close)
- return srv, &calls
-}
-
-func TestElaborateWithLocal_ParsesValidResponse(t *testing.T) {
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Test elaborated task",
- Description: "From local llm",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go build.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- Tags: []string{"build"},
- })
- srv, calls := fakeChatCompletionsServer(t, string(taskBody))
-
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- result, err := elaborateWithLocal(context.Background(), c, "/some/dir", "build the project")
- if err != nil {
- t.Fatalf("elaborateWithLocal: %v", err)
- }
- if result.Name != "Test elaborated task" {
- t.Errorf("Name: %q", result.Name)
- }
- if result.Agent.Instructions != "Run go build." {
- t.Errorf("Instructions: %q", result.Agent.Instructions)
- }
- if got := atomic.LoadInt32(calls); got != 1 {
- t.Errorf("expected 1 call, got %d", got)
- }
-}
-
-func TestElaborateWithLocal_NilClient(t *testing.T) {
- _, err := elaborateWithLocal(context.Background(), nil, "", "p")
- if err == nil || !strings.Contains(err.Error(), "no client") {
- t.Errorf("expected nil-client error, got %v", err)
- }
-}
-
-func TestElaborateWithLocal_BadJSON(t *testing.T) {
- srv, _ := fakeChatCompletionsServer(t, "this is not JSON at all")
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- _, err := elaborateWithLocal(context.Background(), c, "", "p")
- if err == nil || !strings.Contains(err.Error(), "parse JSON") {
- t.Errorf("expected parse error, got %v", err)
- }
-}
-
-// TestElaborateTask_LocalLLMPreferred verifies the dispatcher uses local LLM
-// when SetLLM is configured, and does not invoke claude.
-func TestElaborateTask_LocalLLMPreferred(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Local-elaborated",
- Description: "From local",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work. Tests pass when complete.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- llmSrv, _ := fakeChatCompletionsServer(t, string(taskBody))
- srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"})
- // Point Claude binary at a path that would fail if called.
- srv.elaborateCmdPath = "/nonexistent/claude-should-not-run"
-
- body := `{"prompt":"do work"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
- var got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Local-elaborated" {
- t.Errorf("Name: want Local-elaborated got %q", got.Name)
- }
-}
-
-// TestElaborateTask_LocalFails_FallsBackToClaude verifies the dispatcher
-// falls back to the Claude path when the local LLM returns an error.
-func TestElaborateTask_LocalFails_FallsBackToClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- // Local LLM server that always 500s.
- failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "boom", http.StatusInternalServerError)
- }))
- t.Cleanup(failSrv.Close)
- srv.SetLLM(&llm.Client{Endpoint: failSrv.URL + "/v1", Model: "fake"})
-
- // Configure a working fake Claude binary.
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-fallback",
- Description: "From claude after local failed",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run tests.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"run tests"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
- var got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-fallback" {
- t.Errorf("Name: want Claude-fallback (fallback path) got %q", got.Name)
- }
-}
-
-// TestElaborateTask_NoLocalLLM_UsesClaude verifies that when SetLLM is not
-// called, behavior is unchanged (Claude path still primary).
-func TestElaborateTask_NoLocalLLM_UsesClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-only",
- Description: "no local llm configured",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"do work"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
- var got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-only" {
- t.Errorf("Name: %q", got.Name)
- }
-}
-
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go
deleted file mode 100644
index 32cec3c..0000000
--- a/internal/api/elaborate_test.go
+++ /dev/null
@@ -1,527 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-// createFakeClaude writes a shell script to a temp dir that prints output and exits with the
-// given code. Returns the script path. Used to mock the claude binary in elaborate tests.
-func createFakeClaude(t *testing.T, output string, exitCode int) string {
- t.Helper()
- dir := t.TempDir()
- outputFile := filepath.Join(dir, "output.json")
- if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil {
- t.Fatal(err)
- }
- script := filepath.Join(dir, "claude")
- content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
- t.Fatal(err)
- }
- return script
-}
-
-// hasTool is a test helper that reports whether name is in the tools slice.
-func hasTool(tools []string, name string) bool {
- for _, t := range tools {
- if t == name {
- return true
- }
- }
- return false
-}
-
-// --- sanitizeElaboratedTask unit tests ---
-
-func TestSanitize_AddsWriteWhenInstructionsMentionFileCreation(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Create a new file called output.txt with the results.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in allowed_tools, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsReadWhenEditIsPresent(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Modify the configuration file.",
- AllowedTools: []string{"Edit"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Read") {
- t.Errorf("expected Read added alongside Edit, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_NoDuplicateTools(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Run go test ./...",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- count := 0
- for _, tool := range task.Agent.AllowedTools {
- if tool == "Bash" {
- count++
- }
- }
- if count != 1 {
- t.Errorf("Bash duplicated in allowed_tools: %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsAcceptanceCriteriaWhenMissing(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Do something useful with the codebase.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "acceptance") && !strings.Contains(lower, "done when") {
- t.Error("expected acceptance criteria section appended to instructions")
- }
-}
-
-func TestSanitize_NoopWhenAcceptanceCriteriaAlreadyPresent(t *testing.T) {
- original := "Do something.\n\n## Acceptance Criteria\n- All tests pass."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if task.Agent.Instructions != original {
- t.Errorf("instructions were modified when acceptance criteria were already present")
- }
-}
-
-func TestSanitize_AddsTDDReminderForCodingTaskWithoutTestMention(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "## Acceptance Criteria\nFix the bug.\n\nModify the handler to return 404 instead of 500.",
- AllowedTools: []string{"Edit", "Read"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "tdd") && !strings.Contains(lower, "test") {
- t.Error("expected TDD reminder for coding task without test mention")
- }
-}
-
-func TestSanitize_NoTDDReminderWhenTestsAlreadyMentioned(t *testing.T) {
- original := "## Acceptance Criteria\nAll tests pass.\n\nEdit the file and run go test ./... to verify."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Edit", "Read", "Bash"},
- },
- }
- before := task.Agent.Instructions
- sanitizeElaboratedTask(task)
- // Should NOT add a second TDD block since tests are already mentioned.
- // Count occurrences of "tdd" / "test" — just verify no double-append.
- if strings.Count(strings.ToLower(task.Agent.Instructions), "tdd") > 1 {
- t.Errorf("TDD block added twice; instructions:\n%s", task.Agent.Instructions)
- }
- _ = before
-}
-
-func TestElaboratePrompt_RequiresAcceptanceCriteria(t *testing.T) {
- prompt := buildElaboratePrompt("")
- lower := strings.ToLower(prompt)
- if !strings.Contains(lower, "acceptance criteria") {
- t.Error("elaborate prompt should instruct the model to include acceptance criteria")
- }
-}
-
-func TestElaboratePrompt_RequiresAllRelevantTools(t *testing.T) {
- prompt := buildElaboratePrompt("")
- // Prompt must remind the model to include file-creating tools when needed.
- if !strings.Contains(prompt, "Write") {
- t.Error("elaborate prompt should mention the Write tool so models know to include it")
- }
-}
-
-func TestElaborateTask_SanitizationAppliedToResponse(t *testing.T) {
- srv, _ := testServer(t)
-
- // Elaborator returns a task that needs Write (instructions say "create file")
- // but does NOT include it in allowed_tools.
- task := elaboratedTask{
- Name: "Generate report",
- Description: "Creates a report file.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Create a new file called report.md with the analysis results.\n\n## Acceptance Criteria\n- report.md exists.",
- MaxBudgetUSD: 0.5,
- AllowedTools: []string{"Bash"}, // Write intentionally missing
- },
- Timeout: "15m",
- Priority: "normal",
- Tags: []string{"report"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := `{"prompt":"generate a report"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
-
- var result elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
- if !hasTool(result.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in sanitized allowed_tools, got %v", result.Agent.AllowedTools)
- }
-}
-
-func TestElaboratePrompt_ContainsWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("/some/custom/path")
- if !strings.Contains(prompt, "/some/custom/path") {
- t.Error("prompt should contain the provided workDir")
- }
- if strings.Contains(prompt, "/root/workspace/claudomator") {
- t.Error("prompt should not hardcode /root/workspace/claudomator")
- }
-}
-
-func TestElaboratePrompt_EmptyWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("")
- if strings.Contains(prompt, "/root") {
- t.Error("prompt should not reference /root when workDir is empty")
- }
-}
-
-func TestElaborateTask_Success(t *testing.T) {
- srv, _ := testServer(t)
-
- // Build fake Claude output: {"result": "<task-json>"}
- task := elaboratedTask{
- Name: "Run Go tests with race detector",
- Description: "Runs the Go test suite with -race flag and checks coverage.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go test -race ./... and report results.",
- ProjectDir: "",
- MaxBudgetUSD: 0.5,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "15m",
- Priority: "normal",
- Tags: []string{"testing", "ci"},
- }
- taskJSON, err := json.Marshal(task)
- if err != nil {
- t.Fatal(err)
- }
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, err := json.Marshal(wrapper)
- if err != nil {
- t.Fatal(err)
- }
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := `{"prompt":"run the Go test suite with race detector and fail if coverage < 80%"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
-
- var result elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
- if result.Name == "" {
- t.Error("expected non-empty name")
- }
- if result.Agent.Instructions == "" {
- t.Error("expected non-empty instructions")
- }
-}
-
-func TestElaborateTask_EmptyPrompt(t *testing.T) {
- srv, _ := testServer(t)
-
- body := `{"prompt":""}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]string
- json.NewDecoder(w.Body).Decode(&resp)
- if resp["error"] == "" {
- t.Error("expected error message in response")
- }
-}
-
-func TestElaborateTask_MarkdownFencedJSON(t *testing.T) {
- srv, _ := testServer(t)
-
- // Build a valid task JSON but wrap it in markdown fences as haiku sometimes does.
- task := elaboratedTask{
- Name: "Test task",
- Description: "Does something.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do the thing.",
- MaxBudgetUSD: 0.5,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "15m",
- Priority: "normal",
- Tags: []string{"test"},
- }
- taskJSON, _ := json.Marshal(task)
- fenced := "```json\n" + string(taskJSON) + "\n```"
- wrapper := map[string]string{"result": fenced}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := `{"prompt":"do something"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
-
- var result elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
- t.Fatalf("failed to decode response: %v", err)
- }
- if result.Name != task.Name {
- t.Errorf("name: want %q, got %q", task.Name, result.Name)
- }
-}
-
-func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- // Fake Claude returns something that is not valid JSON.
- srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0)
- // Ensure Gemini fallback also fails so we get the expected 502.
- srv.geminiBinPath = "/nonexistent/gemini"
-
- body := `{"prompt":"do something"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusBadGateway {
- t.Fatalf("status: want 502, got %d; body: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]string
- json.NewDecoder(w.Body).Decode(&resp)
- if resp["error"] == "" {
- t.Error("expected error message in response")
- }
-}
-
-func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, argsFile string) string {
- t.Helper()
- dir := t.TempDir()
- outputFile := filepath.Join(dir, "output.json")
- if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil {
- t.Fatal(err)
- }
- script := filepath.Join(dir, "claude")
- // Use printf to handle arguments safely
- content := fmt.Sprintf("#!/bin/sh\nprintf \"%%s\\n\" \"$@\" > %q\ncat %q\nexit %d\n", argsFile, outputFile, exitCode)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
- t.Fatal(err)
- }
- return script
-}
-
-func TestElaborateTask_WithProjectContext(t *testing.T) {
- srv, _ := testServer(t)
-
- // Create a temporary workspace with CLAUDE.md and .agent/worklog.md
- workDir := t.TempDir()
- claudeContent := "Claude context info"
- sessionContent := "Session state info"
- if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil {
- t.Fatal(err)
- }
-
- // Capture arguments passed to claude
- argsFile := filepath.Join(t.TempDir(), "args.txt")
-
- task := elaboratedTask{
- Name: "Task with context",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- // Modified createFakeClaude to capture arguments
- srv.elaborateCmdPath = createFakeClaudeCapturingArgs(t, string(wrapperJSON), 0, argsFile)
-
- body := fmt.Sprintf(`{"prompt":"do something", "project_dir":"%s"}`, workDir)
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
-
- // Check if captured arguments contain the context
- capturedArgs, err := os.ReadFile(argsFile)
- if err != nil {
- t.Fatal(err)
- }
- argsStr := string(capturedArgs)
- if !strings.Contains(argsStr, claudeContent) {
- t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr)
- }
- if !strings.Contains(argsStr, sessionContent) {
- t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr)
- }
-}
-
-func TestElaborateTask_NoRawNarrativeWithoutExplicitProjectDir(t *testing.T) {
- srv, _ := testServer(t)
- // Point workDir at a temp dir so any accidental write is detectable.
- srv.workDir = t.TempDir()
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{Instructions: "Instructions"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- // No project_dir in request body.
- body := `{"prompt":"do something"}`
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d", w.Code)
- }
-
- time.Sleep(30 * time.Millisecond) // let goroutine run if it was incorrectly triggered
- narrativePath := filepath.Join(srv.workDir, "docs", "RAW_NARRATIVE.md")
- if _, err := os.Stat(narrativePath); err == nil {
- t.Errorf("RAW_NARRATIVE.md should NOT be written when project_dir is not provided by the user")
- }
-}
-
-func TestElaborateTask_AppendsRawNarrative(t *testing.T) {
- srv, _ := testServer(t)
-
- workDir := t.TempDir()
- prompt := "this is my raw request"
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := fmt.Sprintf(`{"prompt":"%s", "project_dir":"%s"}`, prompt, workDir)
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- srv.Handler().ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
-
- // It runs in a goroutine, so wait a bit
- path := filepath.Join(workDir, "docs", "RAW_NARRATIVE.md")
- var data []byte
- var err error
- for i := 0; i < 10; i++ {
- data, err = os.ReadFile(path)
- if err == nil {
- break
- }
- time.Sleep(10 * time.Millisecond)
- }
-
- if err != nil {
- t.Fatalf("failed to read RAW_NARRATIVE.md: %v", err)
- }
- if !strings.Contains(string(data), prompt) {
- t.Errorf("expected RAW_NARRATIVE.md to contain prompt, got %s", string(data))
- }
-}
diff --git a/internal/api/events.go b/internal/api/events.go
new file mode 100644
index 0000000..eefe064
--- /dev/null
+++ b/internal/api/events.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+)
+
+// handleListTaskEvents returns a task's observability event stream in seq order.
+// Pass ?since_seq=N to fetch only events newer than seq N (incremental polling).
+func (s *Server) handleListTaskEvents(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if _, err := s.store.GetTask(id); err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+
+ var sinceSeq int64
+ if v := r.URL.Query().Get("since_seq"); v != "" {
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || n < 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since_seq"})
+ return
+ }
+ sinceSeq = n
+ }
+
+ events, err := s.store.ListEvents(id, sinceSeq)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if events == nil {
+ events = []*event.Event{}
+ }
+ writeJSON(w, http.StatusOK, events)
+}
diff --git a/internal/api/events_test.go b/internal/api/events_test.go
new file mode 100644
index 0000000..456b6d8
--- /dev/null
+++ b/internal/api/events_test.go
@@ -0,0 +1,94 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestListTaskEvents_ReturnsStreamInSeqOrder(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-task", task.StateReady)
+
+ req := httptest.NewRequest("GET", "/api/tasks/ev-task/events", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body %s", w.Code, w.Body.String())
+ }
+ var events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(events) == 0 {
+ t.Fatal("expected state-change events from the walk to READY")
+ }
+ for i := 1; i < len(events); i++ {
+ if events[i].Seq <= events[i-1].Seq {
+ t.Errorf("events not in ascending seq order: %d then %d", events[i-1].Seq, events[i].Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_SinceSeqFilters(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-since", task.StateReady)
+
+ all := getEvents(t, srv, "ev-since", "")
+ if len(all) < 2 {
+ t.Skipf("need at least 2 events to test since_seq, got %d", len(all))
+ }
+ firstSeq := all[0].Seq
+ rest := getEvents(t, srv, "ev-since", "?since_seq="+itoa(firstSeq))
+ for _, e := range rest {
+ if e.Seq <= firstSeq {
+ t.Errorf("since_seq=%d returned event with seq %d", firstSeq, e.Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/tasks/missing/events", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
+func TestListTaskEvents_InvalidSinceSeq(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-bad", task.StateReady)
+ req := httptest.NewRequest("GET", "/api/tasks/ev-bad/events?since_seq=abc", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d", w.Code)
+ }
+}
+
+func getEvents(t *testing.T, srv *Server, taskID, query string) []*event.Event {
+ t.Helper()
+ req := httptest.NewRequest("GET", "/api/tasks/"+taskID+"/events"+query, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("getEvents %s%s: status %d", taskID, query, w.Code)
+ }
+ var events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("getEvents unmarshal: %v", err)
+ }
+ return events
+}
+
+func itoa(n int64) string {
+ return strconv.FormatInt(n, 10)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index afb80ad..4af7325 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/thepeterstone/claudomator/internal/config"
+ "github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/llm"
"github.com/thepeterstone/claudomator/internal/notify"
@@ -40,6 +41,7 @@ type Server struct {
taskLogStore taskLogStore // injectable for tests; defaults to store
questionStore questionStore // injectable for tests; defaults to store
pool *executor.Pool
+ registry *executor.Registry // per-task agent MCP token registry; mounts /mcp when set
hub *Hub
logger *slog.Logger
mux *http.ServeMux
@@ -62,7 +64,8 @@ type Server struct {
pushStore pushSubscriptionStore
dropsDir string
llm *llm.Client
- basePath string // URL prefix the UI is mounted at, e.g. "/claudomator"
+ budget budgetReporter // optional; per-provider spend headroom for GET /api/budget
+ basePath string // URL prefix the UI is mounted at, e.g. "/claudomator"
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -98,6 +101,7 @@ func (s *Server) SetBasePath(p string) {
s.basePath = p
}
+
// Pool returns the executor pool, for graceful shutdown by the caller.
func (s *Server) Pool() *executor.Pool { return s.pool }
@@ -109,7 +113,7 @@ func (s *Server) SetLLM(c *llm.Client) {
}
-func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
+func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
wd, _ := os.Getwd()
s := &Server{
ctx: context.Background(),
@@ -118,6 +122,7 @@ func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, clau
taskLogStore: store,
questionStore: store,
pool: pool,
+ registry: registry,
hub: NewHub(),
logger: logger,
mux: http.NewServeMux(),
@@ -140,7 +145,6 @@ func (s *Server) StartHub() {
}
func (s *Server) routes() {
- s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask)
s.mux.HandleFunc("POST /api/tasks/validate", s.handleValidateTask)
s.mux.HandleFunc("POST /api/tasks", s.handleCreateTask)
s.mux.HandleFunc("GET /api/tasks", s.handleListTasks)
@@ -152,8 +156,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask)
s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks)
s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions)
+ s.mux.HandleFunc("GET /api/tasks/{id}/events", s.handleListTaskEvents)
s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions)
s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats)
+ s.mux.HandleFunc("GET /api/budget", s.handleGetBudget)
s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog)
@@ -189,6 +195,16 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/drops", s.handleListDrops)
s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop)
s.mux.HandleFunc("POST /api/drops", s.handlePostDrop)
+ if s.registry != nil {
+ mcpHandler := executor.NewAgentMCPHandler(s.registry)
+ s.mux.Handle("POST /mcp", mcpHandler)
+ s.mux.Handle("GET /mcp", mcpHandler)
+ s.mux.Handle("DELETE /mcp", mcpHandler)
+ }
+ chatbotHandler := s.chatbotMCPHandler()
+ s.mux.Handle("POST /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("GET /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("DELETE /chatbot/mcp", chatbotHandler)
s.mux.HandleFunc("GET /", s.handleStaticFiles)
}
@@ -280,41 +296,16 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
- tk, err := s.store.GetTask(taskID)
+ msg, err := s.cancelTask(taskID, event.ActorUser)
if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- // If the task is actively running in the pool, cancel it there.
- if s.pool.Cancel(taskID) {
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancellation requested", "task_id": taskID})
- return
- }
- // For non-running tasks (PENDING, QUEUED), transition directly to CANCELLED.
- if !task.ValidTransition(tk.State, task.StateCancelled) {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task cannot be cancelled from state " + string(tk.State)})
- return
- }
- if err := s.store.UpdateTaskState(taskID, task.StateCancelled); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"})
+ writeOpError(w, err)
return
}
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancelled", "task_id": taskID})
+ writeJSON(w, http.StatusOK, map[string]string{"message": msg, "task_id": taskID})
}
func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
-
- tk, err := s.questionStore.GetTask(taskID)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if tk.State != task.StateBlocked {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not blocked"})
- return
- }
-
var input struct {
Answer string `json:"answer"`
}
@@ -322,61 +313,10 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if input.Answer == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "answer is required"})
- return
- }
-
- // Look up the session ID from the most recent execution.
- latest, err := s.questionStore.GetLatestExecution(taskID)
- if err != nil || latest.SessionID == "" {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no resumable session found"})
- return
- }
-
- // Record the Q&A interaction before clearing the question.
- if tk.QuestionJSON != "" {
- var qData struct {
- Text string `json:"text"`
- Options []string `json:"options"`
- }
- if jsonErr := json.Unmarshal([]byte(tk.QuestionJSON), &qData); jsonErr == nil {
- interaction := task.Interaction{
- QuestionText: qData.Text,
- Options: qData.Options,
- Answer: input.Answer,
- AskedAt: tk.UpdatedAt,
- }
- if appendErr := s.questionStore.AppendTaskInteraction(taskID, interaction); appendErr != nil {
- s.logger.Error("failed to append interaction", "taskID", taskID, "error", appendErr)
- }
- }
- }
-
- // Clear the question and transition to QUEUED.
- if err := s.questionStore.UpdateTaskQuestion(taskID, ""); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to clear question"})
+ if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil {
+ writeOpError(w, err)
return
}
- if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"})
- return
- }
-
- // Submit a resume execution. Carry the sandbox path so the runner uses
- // the same working directory where Claude stored its session files.
- resumeExec := &storage.Execution{
- ID: uuid.New().String(),
- TaskID: taskID,
- ResumeSessionID: latest.SessionID,
- ResumeAnswer: input.Answer,
- SandboxDir: latest.SandboxDir,
- }
- if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil {
- writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
- return
- }
-
writeJSON(w, http.StatusOK, map[string]string{"message": "task queued for resume", "task_id": taskID})
}
@@ -702,40 +642,15 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StateCompleted) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be accepted from state %s", t.State),
- })
- return
- }
- if err := s.store.UpdateTaskState(id, task.StateCompleted); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.acceptTask(r.Context(), id, event.ActorUser); err != nil {
+ writeOpError(w, err)
return
}
- if t.StoryID != "" {
- go s.pool.CheckStoryCompletion(r.Context(), t.StoryID)
- }
writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id})
}
func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StatePending) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be rejected from state %s", t.State),
- })
- return
- }
var input struct {
Comment string `json:"comment"`
}
@@ -743,8 +658,8 @@ func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if err := s.store.RejectTask(id, input.Comment); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.rejectTask(id, input.Comment); err != nil {
+ writeOpError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "task rejected", "task_id": id})
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 2530d55..43d91b0 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -98,7 +98,7 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora
"gemini": runner,
}
pool := executor.NewPool(2, runners, store, logger)
- srv := NewServer(store, pool, logger, "claude", "gemini")
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
return srv, store
}
@@ -116,7 +116,7 @@ func (m *mockRunner) ExecLogDir(execID string) string {
return filepath.Join(m.logDir, execID)
}
-func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ executor.AgentChannel) error {
if e.ID == "" {
e.ID = uuid.New().String()
}
@@ -194,7 +194,7 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) {
"gemini": mr,
}
pool := executor.NewPool(2, runners, store, logger)
- srv := NewServer(store, pool, logger, "claude", "gemini")
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
return srv, store
}
@@ -1228,34 +1228,6 @@ func TestServer_AnswerQuestion_UpdateStateFails_Returns500(t *testing.T) {
}
}
-func TestRateLimit_ElaborateRejectsExcess(t *testing.T) {
- srv, _ := testServer(t)
- // Use burst-1 and rate-0 so the second request from the same IP is rejected.
- srv.elaborateLimiter = newIPRateLimiter(0, 1)
-
- makeReq := func(remoteAddr string) int {
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(`{"description":"x"}`))
- req.Header.Set("Content-Type", "application/json")
- req.RemoteAddr = remoteAddr
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
- return w.Code
- }
-
- // First request from IP A: limiter allows it (non-429).
- if code := makeReq("192.0.2.1:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request should not be rate limited, got 429")
- }
- // Second request from IP A: bucket exhausted, must be 429.
- if code := makeReq("192.0.2.1:1234"); code != http.StatusTooManyRequests {
- t.Errorf("second request from same IP should be 429, got %d", code)
- }
- // First request from IP B: separate bucket, not limited.
- if code := makeReq("192.0.2.2:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request from different IP should not be rate limited, got 429")
- }
-}
-
func TestListWorkspaces_RequiresAuth(t *testing.T) {
srv, _ := testServer(t)
srv.SetAPIToken("secret-token")
diff --git a/internal/api/taskops.go b/internal/api/taskops.go
new file mode 100644
index 0000000..881babe
--- /dev/null
+++ b/internal/api/taskops.go
@@ -0,0 +1,253 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// Shared task-operation service layer. Both the REST handlers and the
+// chatbot-facing MCP tools call these methods so the two surfaces cannot drift.
+// Errors are typed so callers can map them to the right transport status.
+
+// errTaskNotFound is returned when a task ID does not resolve. REST maps it to
+// 404; MCP surfaces it as a tool error.
+var errTaskNotFound = errors.New("task not found")
+
+// conflictError signals an operation that is invalid for the task's current
+// state (REST 409).
+type conflictError struct{ msg string }
+
+func (e *conflictError) Error() string { return e.msg }
+
+func conflictf(format string, a ...any) error { return &conflictError{msg: fmt.Sprintf(format, a...)} }
+
+// badRequestError signals malformed or missing input (REST 400).
+type badRequestError struct{ msg string }
+
+func (e *badRequestError) Error() string { return e.msg }
+
+func badRequestf(format string, a ...any) error { return &badRequestError{msg: fmt.Sprintf(format, a...)} }
+
+// submitTaskSpec describes a task a chatbot wants created and immediately run.
+type submitTaskSpec struct {
+ Name string
+ Instructions string
+ Project string
+ RepositoryURL string
+ Model string
+ MaxBudgetUSD float64
+ Timeout time.Duration
+ Tags []string
+}
+
+// submitTask creates a task and queues it for execution in one step. The
+// repository URL is resolved from the named project when not given explicitly,
+// because task.Validate requires it before the executor's lazy resolution runs.
+func (s *Server) submitTask(ctx context.Context, spec submitTaskSpec) (*task.Task, error) {
+ if strings.TrimSpace(spec.Instructions) == "" {
+ return nil, badRequestf("instructions are required")
+ }
+
+ repoURL := spec.RepositoryURL
+ if repoURL == "" && spec.Project != "" {
+ if proj, err := s.store.GetProject(spec.Project); err == nil {
+ repoURL = proj.RemoteURL
+ }
+ }
+ if repoURL == "" {
+ return nil, badRequestf("repository_url is required (pass it directly or a project that resolves to one)")
+ }
+
+ name := strings.TrimSpace(spec.Name)
+ if name == "" {
+ name = firstLine(spec.Instructions, 72)
+ }
+
+ now := time.Now().UTC()
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: name,
+ Project: spec.Project,
+ RepositoryURL: repoURL,
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Model: spec.Model,
+ Instructions: spec.Instructions,
+ MaxBudgetUSD: spec.MaxBudgetUSD,
+ },
+ Priority: task.PriorityNormal,
+ Tags: spec.Tags,
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if spec.Timeout > 0 {
+ t.Timeout.Duration = spec.Timeout
+ }
+ if t.Tags == nil {
+ t.Tags = []string{}
+ }
+
+ if err := task.Validate(t); err != nil {
+ return nil, badRequestf("%s", err.Error())
+ }
+ if err := s.store.CreateTask(t); err != nil {
+ return nil, err
+ }
+ if err := s.store.UpdateTaskStateBy(t.ID, task.StateQueued, event.ActorChatbot); err != nil {
+ return nil, err
+ }
+ t.State = task.StateQueued
+ if err := s.pool.Submit(ctx, t); err != nil {
+ return nil, err
+ }
+ return t, nil
+}
+
+// acceptTask transitions a READY task to COMPLETED.
+func (s *Server) acceptTask(ctx context.Context, id string, actor event.Actor) error {
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if !task.ValidTransition(t.State, task.StateCompleted) {
+ return conflictf("task cannot be accepted from state %s", t.State)
+ }
+ if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, actor); err != nil {
+ return err
+ }
+ if t.StoryID != "" {
+ go s.pool.CheckStoryCompletion(ctx, t.StoryID)
+ }
+ return nil
+}
+
+// rejectTask transitions a READY task back to PENDING with an optional comment.
+func (s *Server) rejectTask(id, comment string) error {
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if !task.ValidTransition(t.State, task.StatePending) {
+ return conflictf("task cannot be rejected from state %s", t.State)
+ }
+ return s.store.RejectTask(id, comment)
+}
+
+// cancelTask cancels a running/queued task, returning a human-readable result.
+func (s *Server) cancelTask(id string, actor event.Actor) (string, error) {
+ tk, err := s.store.GetTask(id)
+ if err != nil {
+ return "", errTaskNotFound
+ }
+ if s.pool.Cancel(id) {
+ return "task cancellation requested", nil
+ }
+ if !task.ValidTransition(tk.State, task.StateCancelled) {
+ return "", conflictf("task cannot be cancelled from state %s", tk.State)
+ }
+ if err := s.store.UpdateTaskStateBy(id, task.StateCancelled, actor); err != nil {
+ return "", err
+ }
+ return "task cancelled", nil
+}
+
+// answerTaskQuestion answers a BLOCKED task's clarification and resumes it.
+func (s *Server) answerTaskQuestion(ctx context.Context, id, answer string) error {
+ tk, err := s.questionStore.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if tk.State != task.StateBlocked {
+ return conflictf("task is not blocked")
+ }
+ if strings.TrimSpace(answer) == "" {
+ return badRequestf("answer is required")
+ }
+
+ latest, err := s.questionStore.GetLatestExecution(id)
+ if err != nil || latest.SessionID == "" {
+ return fmt.Errorf("no resumable session found")
+ }
+
+ if tk.QuestionJSON != "" {
+ var qData struct {
+ Text string `json:"text"`
+ Options []string `json:"options"`
+ }
+ if json.Unmarshal([]byte(tk.QuestionJSON), &qData) == nil {
+ if appendErr := s.questionStore.AppendTaskInteraction(id, task.Interaction{
+ QuestionText: qData.Text,
+ Options: qData.Options,
+ Answer: answer,
+ AskedAt: tk.UpdatedAt,
+ }); appendErr != nil {
+ s.logger.Error("failed to append interaction", "taskID", id, "error", appendErr)
+ }
+ }
+ }
+
+ if err := s.questionStore.UpdateTaskQuestion(id, ""); err != nil {
+ return err
+ }
+ if err := s.questionStore.UpdateTaskState(id, task.StateQueued); err != nil {
+ return err
+ }
+
+ resumeExec := &storage.Execution{
+ ID: uuid.New().String(),
+ TaskID: id,
+ ResumeSessionID: latest.SessionID,
+ ResumeAnswer: answer,
+ SandboxDir: latest.SandboxDir,
+ }
+ return s.pool.SubmitResume(ctx, tk, resumeExec)
+}
+
+// writeOpError maps a service-layer error to the appropriate REST status.
+func writeOpError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, errTaskNotFound):
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+ var ce *conflictError
+ if errors.As(err, &ce) {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": ce.Error()})
+ return
+ }
+ var be *badRequestError
+ if errors.As(err, &be) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": be.Error()})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+}
+
+// firstLine returns the first non-empty line of s, truncated to max runes.
+func firstLine(s string, max int) string {
+ line := s
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ line = s[:i]
+ }
+ line = strings.TrimSpace(line)
+ if r := []rune(line); len(r) > max {
+ return strings.TrimSpace(string(r[:max]))
+ }
+ if line == "" {
+ return "task"
+ }
+ return line
+}
diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go
index c3d7b1f..60fec14 100644
--- a/internal/api/validate_test.go
+++ b/internal/api/validate_test.go
@@ -3,11 +3,32 @@ package api
import (
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
)
+// createFakeClaude writes a stub `claude` script that prints the given output
+// and exits with the given code, returning its path for use as a binary
+// override in tests.
+func createFakeClaude(t *testing.T, output string, exitCode int) string {
+ t.Helper()
+ dir := t.TempDir()
+ outputFile := filepath.Join(dir, "output.json")
+ if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil {
+ t.Fatal(err)
+ }
+ script := filepath.Join(dir, "claude")
+ content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode)
+ if err := os.WriteFile(script, []byte(content), 0755); err != nil {
+ t.Fatal(err)
+ }
+ return script
+}
+
func TestValidateTask_Success(t *testing.T) {
srv, _ := testServer(t)
diff --git a/internal/budget/budget.go b/internal/budget/budget.go
new file mode 100644
index 0000000..60ce9d2
--- /dev/null
+++ b/internal/budget/budget.go
@@ -0,0 +1,122 @@
+// Package budget tracks agent spend against rolling per-provider windows so the
+// dispatcher can refuse (or reroute) paid work that would breach a cap. Local
+// runners are free and simply carry no configured limit.
+package budget
+
+import (
+ "sort"
+ "time"
+)
+
+// DefaultWindow is the rolling spend window when none is configured.
+const DefaultWindow = 5 * time.Hour
+
+// Limits configures per-provider spend caps within a rolling window.
+type Limits struct {
+ // Window is the rolling lookback; DefaultWindow when zero.
+ Window time.Duration
+ // PerProvider maps a provider (claude/gemini/…) to its USD cap within the
+ // window. A missing or non-positive entry means "unlimited" (e.g. local).
+ PerProvider map[string]float64
+}
+
+// SpendSource supplies total spend per provider since a given time.
+type SpendSource interface {
+ SpendByProviderSince(since time.Time) (map[string]float64, error)
+}
+
+// Headroom describes remaining budget for one provider in the current window.
+type Headroom struct {
+ Provider string `json:"provider"`
+ Limited bool `json:"limited"`
+ Limit float64 `json:"limit_usd"`
+ Spent float64 `json:"spent_usd"`
+ Remaining float64 `json:"remaining_usd"`
+ // Fraction is the share of the cap still available, 0..1 (0 when unlimited).
+ Fraction float64 `json:"fraction_remaining"`
+}
+
+// Accountant answers spend questions against a SpendSource and Limits.
+type Accountant struct {
+ src SpendSource
+ lim Limits
+ now func() time.Time
+}
+
+// New builds an Accountant. A zero Window defaults to DefaultWindow.
+func New(src SpendSource, lim Limits) *Accountant {
+ if lim.Window <= 0 {
+ lim.Window = DefaultWindow
+ }
+ return &Accountant{src: src, lim: lim, now: time.Now}
+}
+
+func (a *Accountant) since() time.Time { return a.now().Add(-a.lim.Window) }
+
+func (a *Accountant) headroomFrom(provider string, spends map[string]float64) Headroom {
+ limit := a.lim.PerProvider[provider]
+ if limit <= 0 {
+ return Headroom{Provider: provider, Limited: false}
+ }
+ spent := spends[provider]
+ remaining := limit - spent
+ if remaining < 0 {
+ remaining = 0
+ }
+ return Headroom{
+ Provider: provider,
+ Limited: true,
+ Limit: limit,
+ Spent: spent,
+ Remaining: remaining,
+ Fraction: remaining / limit,
+ }
+}
+
+// Headroom returns the remaining budget for one provider in the current window.
+func (a *Accountant) Headroom(provider string) (Headroom, error) {
+ if a.lim.PerProvider[provider] <= 0 {
+ return Headroom{Provider: provider, Limited: false}, nil
+ }
+ spends, err := a.src.SpendByProviderSince(a.since())
+ if err != nil {
+ return Headroom{}, err
+ }
+ return a.headroomFrom(provider, spends), nil
+}
+
+// Allow reports whether starting a task on provider that may cost up to estCost
+// is permitted without breaching the window cap. Unlimited providers always
+// pass. estCost is typically the task's max_budget_usd (0 = unknown).
+func (a *Accountant) Allow(provider string, estCost float64) (bool, error) {
+ h, err := a.Headroom(provider)
+ if err != nil {
+ return false, err
+ }
+ if !h.Limited {
+ return true, nil
+ }
+ return h.Spent+estCost <= h.Limit, nil
+}
+
+// All returns headroom for every configured provider, sorted by name, in a
+// single spend query.
+func (a *Accountant) All() ([]Headroom, error) {
+ if len(a.lim.PerProvider) == 0 {
+ return []Headroom{}, nil
+ }
+ spends, err := a.src.SpendByProviderSince(a.since())
+ if err != nil {
+ return nil, err
+ }
+ providers := make([]string, 0, len(a.lim.PerProvider))
+ for p := range a.lim.PerProvider {
+ providers = append(providers, p)
+ }
+ sort.Strings(providers)
+ out := make([]Headroom, 0, len(providers))
+ for _, p := range providers {
+ out = append(out, a.headroomFrom(p, spends))
+ }
+ return out, nil
+}
diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go
new file mode 100644
index 0000000..c257c91
--- /dev/null
+++ b/internal/budget/budget_test.go
@@ -0,0 +1,112 @@
+package budget
+
+import (
+ "testing"
+ "time"
+)
+
+type fakeSpend struct {
+ spends map[string]float64
+ since time.Time
+}
+
+func (f *fakeSpend) SpendByProviderSince(since time.Time) (map[string]float64, error) {
+ f.since = since
+ return f.spends, nil
+}
+
+func newAcct(spends map[string]float64, lim Limits) (*Accountant, *fakeSpend) {
+ src := &fakeSpend{spends: spends}
+ a := New(src, lim)
+ a.now = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) }
+ return a, src
+}
+
+func TestNew_DefaultsWindow(t *testing.T) {
+ a, _ := newAcct(nil, Limits{})
+ if a.lim.Window != DefaultWindow {
+ t.Errorf("want default window %v, got %v", DefaultWindow, a.lim.Window)
+ }
+}
+
+func TestHeadroom_UnlimitedWhenNoLimit(t *testing.T) {
+ a, _ := newAcct(map[string]float64{"local": 999}, Limits{PerProvider: map[string]float64{"claude": 10}})
+ h, err := a.Headroom("local")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h.Limited {
+ t.Errorf("local should be unlimited, got %+v", h)
+ }
+}
+
+func TestHeadroom_ComputesRemainingAndFraction(t *testing.T) {
+ a, src := newAcct(map[string]float64{"claude": 4}, Limits{Window: 5 * time.Hour, PerProvider: map[string]float64{"claude": 10}})
+ h, err := a.Headroom("claude")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !h.Limited || h.Limit != 10 || h.Spent != 4 || h.Remaining != 6 {
+ t.Errorf("unexpected headroom: %+v", h)
+ }
+ if h.Fraction != 0.6 {
+ t.Errorf("fraction: want 0.6, got %v", h.Fraction)
+ }
+ // Window lookback is now-5h.
+ want := time.Date(2026, 5, 26, 7, 0, 0, 0, time.UTC)
+ if !src.since.Equal(want) {
+ t.Errorf("since: want %v, got %v", want, src.since)
+ }
+}
+
+func TestHeadroom_ClampsNegativeRemaining(t *testing.T) {
+ a, _ := newAcct(map[string]float64{"claude": 15}, Limits{PerProvider: map[string]float64{"claude": 10}})
+ h, _ := a.Headroom("claude")
+ if h.Remaining != 0 {
+ t.Errorf("remaining should clamp to 0, got %v", h.Remaining)
+ }
+}
+
+func TestAllow(t *testing.T) {
+ a, _ := newAcct(map[string]float64{"claude": 8}, Limits{PerProvider: map[string]float64{"claude": 10}})
+
+ if ok, _ := a.Allow("claude", 1.5); !ok {
+ t.Error("8 + 1.5 <= 10 should be allowed")
+ }
+ if ok, _ := a.Allow("claude", 3); ok {
+ t.Error("8 + 3 > 10 should be denied")
+ }
+ if ok, _ := a.Allow("local", 1000); !ok {
+ t.Error("unlimited provider should always allow")
+ }
+}
+
+func TestAll_SortedAndSingleQuery(t *testing.T) {
+ a, src := newAcct(map[string]float64{"claude": 2, "gemini": 1},
+ Limits{PerProvider: map[string]float64{"gemini": 5, "claude": 10}})
+ calls := 0
+ orig := src.spends
+ a2 := New(&countingSpend{spends: orig, calls: &calls}, a.lim)
+ a2.now = a.now
+
+ hs, err := a2.All()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hs) != 2 || hs[0].Provider != "claude" || hs[1].Provider != "gemini" {
+ t.Errorf("want [claude gemini] sorted, got %+v", hs)
+ }
+ if calls != 1 {
+ t.Errorf("All should issue exactly one spend query, got %d", calls)
+ }
+}
+
+type countingSpend struct {
+ spends map[string]float64
+ calls *int
+}
+
+func (c *countingSpend) SpendByProviderSince(time.Time) (map[string]float64, error) {
+ *c.calls++
+ return c.spends, nil
+}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index ae42780..f687e14 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -11,6 +11,8 @@ import (
"time"
"github.com/thepeterstone/claudomator/internal/api"
+ "github.com/thepeterstone/claudomator/internal/budget"
+ "github.com/thepeterstone/claudomator/internal/config"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/notify"
"github.com/thepeterstone/claudomator/internal/storage"
@@ -34,7 +36,7 @@ func newServeCmd() *cobra.Command {
},
}
- cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address")
+ cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8484", "listen address (loopback by default; set external_bind_allowed=true in config to bind externally)")
cmd.Flags().StringVar(&basePath, "base-path", "/claudomator", "URL prefix the UI is served under (e.g. /claudomator-oss)")
cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces")
cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents")
@@ -44,6 +46,9 @@ func newServeCmd() *cobra.Command {
}
func serve(addr, basePath string) error {
+ if err := config.ValidateBindAddr(addr, cfg.ExternalBindAllowed); err != nil {
+ return err
+ }
if err := cfg.EnsureDirs(); err != nil {
return fmt.Errorf("creating dirs: %w", err)
}
@@ -81,6 +86,10 @@ func serve(addr, basePath string) error {
claudeConfigDir := cfg.ClaudeConfigDir
repoDir, _ := os.Getwd()
+
+ // Shared per-task agent MCP token registry: runners mint tokens; the API
+ // server mounts /mcp and resolves them.
+ agentRegistry := executor.NewRegistry()
runners := map[string]executor.Runner{}
// ContainerRunner: binaries are resolved via PATH inside the container image,
@@ -96,6 +105,7 @@ func serve(addr, basePath string) error {
ClaudeConfigDir: claudeConfigDir,
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
+ Registry: agentRegistry,
}
}
if cfg.Runners.GeminiEnabled() {
@@ -109,6 +119,7 @@ func serve(addr, basePath string) error {
ClaudeConfigDir: claudeConfigDir,
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
+ Registry: agentRegistry,
}
}
if cfg.Runners.ContainerEnabled() {
@@ -122,6 +133,7 @@ func serve(addr, basePath string) error {
ClaudeConfigDir: claudeConfigDir,
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
+ Registry: agentRegistry,
}
}
@@ -136,7 +148,6 @@ func serve(addr, basePath string) error {
logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model)
}
-
pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger)
pool.Classifier = &executor.Classifier{
LLM: localClient,
@@ -146,6 +157,23 @@ func serve(addr, basePath string) error {
pool.LLM = localClient
}
+ // Budget accountant: gate paid providers against rolling per-provider caps.
+ // With no limits configured this is a no-op.
+ var accountant *budget.Accountant
+ if len(cfg.Budget.Provider5hUSD) > 0 {
+ window := budget.DefaultWindow
+ if cfg.Budget.Window != "" {
+ if d, derr := time.ParseDuration(cfg.Budget.Window); derr == nil {
+ window = d
+ } else {
+ logger.Warn("invalid budget.window; using default", "value", cfg.Budget.Window, "default", window)
+ }
+ }
+ accountant = budget.New(store, budget.Limits{Window: window, PerProvider: cfg.Budget.Provider5hUSD})
+ pool.Budget = accountant
+ logger.Info("budget gating enabled", "window", window, "limits", cfg.Budget.Provider5hUSD)
+ }
+
if err := store.SeedProjects(); err != nil {
logger.Error("failed to seed projects", "error", err)
}
@@ -154,8 +182,11 @@ func serve(addr, basePath string) error {
pool.RecoverStaleQueued(context.Background())
pool.RecoverStaleBlocked()
- srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath)
+ srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath)
srv.SetBasePath(basePath)
+ if accountant != nil {
+ srv.SetBudget(accountant)
+ }
// Configure notifiers: combine webhook (if set) with web push.
notifiers := []notify.Notifier{}
@@ -185,6 +216,11 @@ func serve(addr, basePath string) error {
}
srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects)
+ if cfg.APIToken != "" {
+ srv.SetAPIToken(cfg.APIToken)
+ logger.Info("API token configured; chatbot MCP endpoint enabled at /chatbot/mcp")
+ }
+
// Register scripts.
wd, _ := os.Getwd()
srv.SetScripts(api.ScriptRegistry{
@@ -238,4 +274,3 @@ func serve(addr, basePath string) error {
}
return nil
}
-
diff --git a/internal/config/bind.go b/internal/config/bind.go
new file mode 100644
index 0000000..f117385
--- /dev/null
+++ b/internal/config/bind.go
@@ -0,0 +1,44 @@
+package config
+
+import (
+ "fmt"
+ "net"
+)
+
+// ValidateBindAddr enforces loopback-by-default binding. It returns an error
+// when addr resolves to a non-loopback interface and externalAllowed is false,
+// so the server fails loud rather than silently exposing itself. Loopback
+// addresses (127.0.0.1, ::1, localhost) always pass.
+func ValidateBindAddr(addr string, externalAllowed bool) error {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ // No port (or malformed): treat the whole string as the host.
+ host = addr
+ }
+ if isLoopbackHost(host) {
+ return nil
+ }
+ if externalAllowed {
+ return nil
+ }
+ return fmt.Errorf(
+ "refusing to bind non-loopback address %q: set external_bind_allowed = true to override, "+
+ "but prefer fronting external access with a reverse proxy (Tailscale / Cloudflare Access / Caddy)",
+ addr,
+ )
+}
+
+// isLoopbackHost reports whether host is a loopback target. An empty host
+// (e.g. ":8484") or 0.0.0.0/:: binds all interfaces and is NOT loopback.
+func isLoopbackHost(host string) bool {
+ switch host {
+ case "localhost":
+ return true
+ case "":
+ return false
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ return ip.IsLoopback()
+ }
+ return false
+}
diff --git a/internal/config/bind_test.go b/internal/config/bind_test.go
new file mode 100644
index 0000000..8e0ab4b
--- /dev/null
+++ b/internal/config/bind_test.go
@@ -0,0 +1,27 @@
+package config
+
+import "testing"
+
+func TestValidateBindAddr(t *testing.T) {
+ cases := []struct {
+ addr string
+ external bool
+ wantErr bool
+ }{
+ {"127.0.0.1:8484", false, false}, // loopback ok
+ {"localhost:8484", false, false}, // loopback ok
+ {"[::1]:8484", false, false}, // ipv6 loopback ok
+ {":8484", false, true}, // all interfaces, not allowed
+ {"0.0.0.0:8484", false, true}, // all interfaces, not allowed
+ {"192.168.1.10:8484", false, true}, // LAN ip, not allowed
+ {":8484", true, false}, // all interfaces, explicitly allowed
+ {"0.0.0.0:8484", true, false}, // explicitly allowed
+ {"192.168.1.10:8484", true, false}, // explicitly allowed
+ }
+ for _, c := range cases {
+ err := ValidateBindAddr(c.addr, c.external)
+ if (err != nil) != c.wantErr {
+ t.Errorf("ValidateBindAddr(%q, external=%v): err=%v, wantErr=%v", c.addr, c.external, err, c.wantErr)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 892d3f7..80777ff 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -80,6 +80,7 @@ type Config struct {
WebhookURL string `toml:"webhook_url"`
WorkspaceRoot string `toml:"workspace_root"`
WebhookSecret string `toml:"webhook_secret"`
+ APIToken string `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint
Projects []Project `toml:"projects"`
VAPIDPublicKey string `toml:"vapid_public_key"`
VAPIDPrivateKey string `toml:"vapid_private_key"`
@@ -87,6 +88,16 @@ type Config struct {
ClaudeConfigDir string `toml:"claude_config_dir"`
LocalModel LocalModel `toml:"local_model"`
Runners RunnersConfig `toml:"runners"`
+ Budget BudgetConfig `toml:"budget"`
+ // ExternalBindAllowed must be explicitly true to bind a non-loopback address.
+ ExternalBindAllowed bool `toml:"external_bind_allowed"`
+}
+
+// BudgetConfig configures rolling per-provider spend caps. With no providers
+// set, budget gating is disabled.
+type BudgetConfig struct {
+ Window string `toml:"window"`
+ Provider5hUSD map[string]float64 `toml:"provider_5h_usd"`
}
func Default() (*Config, error) {
@@ -110,7 +121,7 @@ func Default() (*Config, error) {
GeminiImage: "claudomator-agent:latest",
MaxConcurrent: 3,
DefaultTimeout: "15m",
- ServerAddr: ":8484",
+ ServerAddr: "127.0.0.1:8484",
WorkspaceRoot: "/workspace",
ClaudeConfigDir: "/workspace/claudomator/credentials/claude",
}, nil
diff --git a/internal/event/event.go b/internal/event/event.go
new file mode 100644
index 0000000..2573fde
--- /dev/null
+++ b/internal/event/event.go
@@ -0,0 +1,50 @@
+// Package event defines the Event type and canonical kinds/actors for the
+// task observability stream. Events are immutable, per-task, monotonically
+// sequenced records persisted by internal/storage.
+package event
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// Kind enumerates the canonical event types.
+type Kind string
+
+const (
+ KindStateChange Kind = "state_change"
+ KindAgentMessage Kind = "agent_message"
+ KindClarificationRequest Kind = "clarification_request"
+ KindClarificationAnswer Kind = "clarification_answer"
+ KindHumanMessage Kind = "human_message"
+ KindSummary Kind = "summary"
+ KindCommitMade Kind = "commit_made"
+ KindSubtaskSpawned Kind = "subtask_spawned"
+ KindCostReport Kind = "cost_report"
+ KindExecutionStarted Kind = "execution_started"
+ KindExecutionEnded Kind = "execution_ended"
+)
+
+// Actor identifies the originator of an event.
+type Actor string
+
+const (
+ ActorAgent Actor = "agent"
+ ActorUser Actor = "user"
+ ActorChatbot Actor = "chatbot"
+ ActorSystem Actor = "system"
+ ActorWebhook Actor = "webhook"
+)
+
+// Event is a single timestamped record on a task's audit/observability stream.
+// Once written, events are append-only — the seq column is per-task monotonic
+// and assigned by the storage layer on insert.
+type Event struct {
+ ID string `json:"id"`
+ TaskID string `json:"task_id"`
+ Seq int64 `json:"seq"`
+ Timestamp time.Time `json:"timestamp"`
+ Kind Kind `json:"kind"`
+ Actor Actor `json:"actor"`
+ Payload json.RawMessage `json:"payload"`
+}
diff --git a/internal/event/event_test.go b/internal/event/event_test.go
new file mode 100644
index 0000000..a2cd143
--- /dev/null
+++ b/internal/event/event_test.go
@@ -0,0 +1,42 @@
+package event
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestEvent_JSONRoundTrip(t *testing.T) {
+ original := Event{
+ ID: "evt-1",
+ TaskID: "task-1",
+ Seq: 42,
+ Timestamp: time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC),
+ Kind: KindClarificationRequest,
+ Actor: ActorAgent,
+ Payload: json.RawMessage(`{"question":"which env?"}`),
+ }
+
+ data, err := json.Marshal(original)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got Event
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got.ID != original.ID || got.TaskID != original.TaskID || got.Seq != original.Seq {
+ t.Errorf("id/task/seq mismatch: got %+v want %+v", got, original)
+ }
+ if !got.Timestamp.Equal(original.Timestamp) {
+ t.Errorf("timestamp mismatch: got %v want %v", got.Timestamp, original.Timestamp)
+ }
+ if got.Kind != original.Kind || got.Actor != original.Actor {
+ t.Errorf("kind/actor mismatch: got %v/%v want %v/%v", got.Kind, got.Actor, original.Kind, original.Actor)
+ }
+ if string(got.Payload) != string(original.Payload) {
+ t.Errorf("payload mismatch: got %s want %s", got.Payload, original.Payload)
+ }
+}
diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go
new file mode 100644
index 0000000..4368031
--- /dev/null
+++ b/internal/executor/agentmcp.go
@@ -0,0 +1,160 @@
+package executor
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// Registry maps per-task MCP bearer tokens to a built agent MCP server. A token
+// is minted when a runner spawns an agent subprocess and revoked when it exits,
+// so the server resolves task context from the token alone and never trusts an
+// agent-supplied task ID.
+type Registry struct {
+ mu sync.RWMutex
+ servers map[string]*mcp.Server
+}
+
+func NewRegistry() *Registry {
+ return &Registry{servers: make(map[string]*mcp.Server)}
+}
+
+// Mint creates a token bound to a freshly built MCP server for ch.
+func (r *Registry) Mint(ch AgentChannel) (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ token := hex.EncodeToString(buf)
+ r.mu.Lock()
+ r.servers[token] = newAgentServer(ch)
+ r.mu.Unlock()
+ return token, nil
+}
+
+func (r *Registry) server(token string) (*mcp.Server, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ s, ok := r.servers[token]
+ return s, ok
+}
+
+func (r *Registry) Revoke(token string) {
+ r.mu.Lock()
+ delete(r.servers, token)
+ r.mu.Unlock()
+}
+
+type askUserInput struct {
+ Question string `json:"question" jsonschema:"the question to ask the user; phrase it as a real question ending with a question mark"`
+ Options []string `json:"options,omitempty" jsonschema:"optional list of suggested answer choices"`
+}
+
+type reportSummaryInput struct {
+ Summary string `json:"summary" jsonschema:"a 2-5 sentence summary of what you did and the outcome"`
+}
+
+type spawnSubtaskInput struct {
+ Name string `json:"name" jsonschema:"short descriptive name for the subtask"`
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"`
+}
+
+type recordProgressInput struct {
+ Message string `json:"message" jsonschema:"a short progress note describing what you are doing"`
+}
+
+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.
+func newAgentServer(ch AgentChannel) *mcp.Server {
+ s := mcp.NewServer(&mcp.Implementation{Name: "claudomator", Version: "1"}, nil)
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "ask_user",
+ Description: "Ask the user a question when you genuinely need a decision to proceed. Your turn ends after calling this; the task is resumed with the user's answer. Prefer making a reasonable decision and noting it in report_summary over asking.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in askUserInput) (*mcp.CallToolResult, any, error) {
+ q := map[string]any{"text": in.Question}
+ if len(in.Options) > 0 {
+ q["options"] = in.Options
+ }
+ payload, _ := json.Marshal(q)
+ ans, err := ch.AskUser(ctx, string(payload))
+ if errors.Is(err, ErrAgentBlocked) {
+ return textResult("Question recorded. End your turn now without calling any more tools; the task will be resumed once the user answers."), nil, nil
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult(ans), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "report_summary",
+ Description: "Record a concise summary of what you accomplished. Call this before finishing.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in reportSummaryInput) (*mcp.CallToolResult, any, error) {
+ if err := ch.ReportSummary(ctx, in.Summary); err != nil {
+ return nil, nil, err
+ }
+ return textResult("Summary recorded."), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) {
+ id, err := ch.SpawnSubtask(ctx, SubtaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult("Created subtask " + id), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "record_progress",
+ Description: "Record a short progress note that appears in the task timeline.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in recordProgressInput) (*mcp.CallToolResult, any, error) {
+ if err := ch.RecordProgress(ctx, in.Message); err != nil {
+ return nil, nil, err
+ }
+ return textResult("Noted."), nil, nil
+ })
+
+ return s
+}
+
+func bearerToken(r *http.Request) string {
+ h := r.Header.Get("Authorization")
+ if h == "" {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
+}
+
+// NewAgentMCPHandler returns the HTTP handler for the per-task agent MCP server.
+// It resolves the request's bearer token to the server for that task's run;
+// unknown tokens yield a 400 from the underlying handler.
+func NewAgentMCPHandler(reg *Registry) http.Handler {
+ return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
+ s, ok := reg.server(bearerToken(r))
+ if !ok {
+ return nil
+ }
+ return s
+ }, nil)
+}
diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go
new file mode 100644
index 0000000..3973af0
--- /dev/null
+++ b/internal/executor/agentmcp_test.go
@@ -0,0 +1,218 @@
+package executor
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// recordingChannel is a fake AgentChannel that records tool invocations.
+type recordingChannel struct {
+ asked string
+ summary string
+ spawned []SubtaskSpec
+ progress []string
+ spawnID string
+}
+
+func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) {
+ c.asked = q
+ return "", ErrAgentBlocked
+}
+func (c *recordingChannel) ReportSummary(_ context.Context, s string) error {
+ c.summary = s
+ return nil
+}
+func (c *recordingChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) {
+ c.spawned = append(c.spawned, spec)
+ return c.spawnID, nil
+}
+func (c *recordingChannel) RecordProgress(_ context.Context, m string) error {
+ c.progress = append(c.progress, m)
+ return nil
+}
+
+func resultText(t *testing.T, res *mcp.CallToolResult) string {
+ t.Helper()
+ if len(res.Content) == 0 {
+ t.Fatal("expected content in tool result")
+ }
+ tc, ok := res.Content[0].(*mcp.TextContent)
+ if !ok {
+ t.Fatalf("expected TextContent, got %T", res.Content[0])
+ }
+ return tc.Text
+}
+
+func TestRegistry_MintLookupRevoke(t *testing.T) {
+ reg := NewRegistry()
+ tok, err := reg.Mint(&recordingChannel{})
+ if err != nil {
+ t.Fatalf("Mint: %v", err)
+ }
+ if tok == "" {
+ t.Fatal("expected non-empty token")
+ }
+ if _, ok := reg.server(tok); !ok {
+ t.Error("expected server for minted token")
+ }
+ if _, ok := reg.server("nonsense"); ok {
+ t.Error("expected no server for unknown token")
+ }
+ reg.Revoke(tok)
+ if _, ok := reg.server(tok); ok {
+ t.Error("expected no server after revoke")
+ }
+}
+
+func TestRegistry_MintUniqueTokens(t *testing.T) {
+ reg := NewRegistry()
+ a, _ := reg.Mint(&recordingChannel{})
+ b, _ := reg.Mint(&recordingChannel{})
+ if a == b {
+ t.Error("expected unique tokens per mint")
+ }
+}
+
+// connectInMemory wires a client to a server over the SDK's in-memory transport.
+func connectInMemory(t *testing.T, srv *mcp.Server) *mcp.ClientSession {
+ t.Helper()
+ ctx := context.Background()
+ clientT, serverT := mcp.NewInMemoryTransports()
+ if _, err := srv.Connect(ctx, serverT, nil); err != nil {
+ t.Fatalf("server connect: %v", err)
+ }
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(ctx, clientT, nil)
+ if err != nil {
+ t.Fatalf("client connect: %v", err)
+ }
+ t.Cleanup(func() { _ = cs.Close() })
+ return cs
+}
+
+func TestAgentServer_AskUser_RecordsAndInstructsStop(t *testing.T) {
+ ch := &recordingChannel{}
+ cs := connectInMemory(t, newAgentServer(ch))
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "ask_user",
+ Arguments: map[string]any{"question": "Proceed?", "options": []string{"yes", "no"}},
+ })
+ if err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if !strings.Contains(ch.asked, `"text":"Proceed?"`) || !strings.Contains(ch.asked, "yes") {
+ t.Errorf("channel did not receive question+options: %q", ch.asked)
+ }
+ if txt := resultText(t, res); !strings.Contains(strings.ToLower(txt), "recorded") {
+ t.Errorf("expected stop instruction, got %q", txt)
+ }
+}
+
+func TestAgentServer_ReportSummary(t *testing.T) {
+ ch := &recordingChannel{}
+ cs := connectInMemory(t, newAgentServer(ch))
+ if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "report_summary",
+ Arguments: map[string]any{"summary": "did the work"},
+ }); err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if ch.summary != "did the work" {
+ t.Errorf("summary not recorded, got %q", ch.summary)
+ }
+}
+
+func TestAgentServer_SpawnSubtask(t *testing.T) {
+ ch := &recordingChannel{spawnID: "sub-1"}
+ cs := connectInMemory(t, newAgentServer(ch))
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "spawn_subtask",
+ Arguments: map[string]any{"name": "child", "instructions": "do it", "model": "sonnet"},
+ })
+ if err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if len(ch.spawned) != 1 || ch.spawned[0].Name != "child" || ch.spawned[0].Instructions != "do it" || ch.spawned[0].Model != "sonnet" {
+ t.Errorf("subtask spec not propagated: %+v", ch.spawned)
+ }
+ if txt := resultText(t, res); !strings.Contains(txt, "sub-1") {
+ t.Errorf("expected returned subtask ID in result, got %q", txt)
+ }
+}
+
+func TestAgentServer_RecordProgress(t *testing.T) {
+ ch := &recordingChannel{}
+ cs := connectInMemory(t, newAgentServer(ch))
+ if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "record_progress",
+ Arguments: map[string]any{"message": "halfway"},
+ }); err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if len(ch.progress) != 1 || ch.progress[0] != "halfway" {
+ t.Errorf("progress not recorded: %+v", ch.progress)
+ }
+}
+
+type bearerRT struct {
+ token string
+ base http.RoundTripper
+}
+
+func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
+ r = r.Clone(r.Context())
+ if b.token != "" {
+ r.Header.Set("Authorization", "Bearer "+b.token)
+ }
+ return b.base.RoundTrip(r)
+}
+
+func TestAgentMCPHandler_HTTP_AuthAndDispatch(t *testing.T) {
+ ch := &recordingChannel{}
+ reg := NewRegistry()
+ tok, _ := reg.Mint(ch)
+
+ httpSrv := httptest.NewServer(NewAgentMCPHandler(reg))
+ defer httpSrv.Close()
+
+ ctx := context.Background()
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{
+ Endpoint: httpSrv.URL,
+ HTTPClient: &http.Client{Transport: bearerRT{token: tok, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("client connect with valid token: %v", err)
+ }
+ defer cs.Close()
+
+ if _, err := cs.CallTool(ctx, &mcp.CallToolParams{
+ Name: "record_progress",
+ Arguments: map[string]any{"message": "over http"},
+ }); err != nil {
+ t.Fatalf("CallTool over HTTP: %v", err)
+ }
+ if len(ch.progress) != 1 || ch.progress[0] != "over http" {
+ t.Errorf("HTTP dispatch did not reach channel: %+v", ch.progress)
+ }
+}
+
+func TestAgentMCPHandler_HTTP_BadTokenRejected(t *testing.T) {
+ reg := NewRegistry()
+ httpSrv := httptest.NewServer(NewAgentMCPHandler(reg))
+ defer httpSrv.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: httpSrv.URL,
+ HTTPClient: &http.Client{Transport: bearerRT{token: "invalid", base: http.DefaultTransport}},
+ }, nil)
+ if err == nil {
+ t.Fatal("expected connect to fail with invalid token")
+ }
+}
diff --git a/internal/executor/channel.go b/internal/executor/channel.go
new file mode 100644
index 0000000..8605ffa
--- /dev/null
+++ b/internal/executor/channel.go
@@ -0,0 +1,159 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+ "github.com/google/uuid"
+)
+
+// ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot
+// deliver an answer within the current run — e.g. the file transport, which can
+// only write-and-exit. Runners translate it into a *BlockedError so the task
+// blocks and resumes later. Transports that can suspend the agent in-session
+// (MCP, Phase 2) instead block and return the answer.
+var ErrAgentBlocked = errors.New("agent blocked awaiting user input")
+
+// SubtaskSpec describes a child task an agent wants to spawn.
+type SubtaskSpec struct {
+ Name string
+ Instructions string
+ Model string
+ MaxBudgetUSD float64
+}
+
+// AgentChannel is how a Runner reports agent-originated signals to the rest of
+// the system. Implementations translate these into stored artifacts and events.
+// The transport by which a Runner detects these signals — post-exit files
+// today, MCP/tool-use in Phase 2 — is the Runner's private concern.
+type AgentChannel interface {
+ // AskUser records that the agent needs human input. Transports that can
+ // suspend the agent in-session return the answer; those that cannot return
+ // ErrAgentBlocked.
+ AskUser(ctx context.Context, questionJSON string) (answer string, err error)
+ // ReportSummary records the agent's final summary text.
+ ReportSummary(ctx context.Context, summary string) error
+ // SpawnSubtask creates a child task and returns its ID.
+ SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error)
+ // RecordProgress records a free-form progress note from the agent.
+ RecordProgress(ctx context.Context, message string) error
+}
+
+// channelStore is the subset of storage the default channel needs.
+type channelStore interface {
+ CreateTask(t *task.Task) error
+ CreateEvent(e *event.Event) error
+}
+
+// pendingAsker is implemented by channels that buffer an ask_user call so the
+// runner can convert it into a BlockedError after the agent subprocess exits.
+type pendingAsker interface {
+ PendingQuestion() (questionJSON string, blocked bool)
+}
+
+// channelPendingQuestion reports whether the agent asked a question via the
+// channel during the run (the MCP transport's ask_user path).
+func channelPendingQuestion(ch AgentChannel) (string, bool) {
+ if pa, ok := ch.(pendingAsker); ok {
+ return pa.PendingQuestion()
+ }
+ return "", false
+}
+
+// storeChannel is the default AgentChannel backed by storage. Summary and
+// question signals are buffered here under a mutex (they may arrive on an MCP
+// handler goroutine mid-run); the pool flushes the summary to the execution and
+// the runner reads the pending question to build a BlockedError after the
+// subprocess exits. SpawnSubtask/RecordProgress write immediately.
+type storeChannel struct {
+ store channelStore
+ taskID string
+
+ mu sync.Mutex
+ summary string
+ summarySet bool
+ question string
+ blocked bool
+}
+
+var _ AgentChannel = (*storeChannel)(nil)
+
+func newStoreChannel(store channelStore, taskID string) *storeChannel {
+ return &storeChannel{store: store, taskID: taskID}
+}
+
+// AskUser buffers the question and flags the run as blocked. The default
+// transport cannot deliver an answer in-session, so it returns ErrAgentBlocked;
+// the runner converts the buffered question into a BlockedError after exit, and
+// question persistence is owned by the pool's BlockedError handling.
+func (c *storeChannel) AskUser(_ context.Context, questionJSON string) (string, error) {
+ c.mu.Lock()
+ c.question = questionJSON
+ c.blocked = true
+ c.mu.Unlock()
+ return "", ErrAgentBlocked
+}
+
+// ReportSummary buffers the summary; the pool flushes it onto the execution
+// after the run (preferring it over its extract/synthesize fallbacks).
+func (c *storeChannel) ReportSummary(_ context.Context, summary string) error {
+ c.mu.Lock()
+ c.summary = summary
+ c.summarySet = true
+ c.mu.Unlock()
+ return nil
+}
+
+// ReportedSummary returns the buffered summary if report_summary was called.
+func (c *storeChannel) ReportedSummary() (string, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.summary, c.summarySet
+}
+
+// PendingQuestion returns the buffered ask_user question if the run is blocked.
+func (c *storeChannel) PendingQuestion() (string, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.question, c.blocked
+}
+
+func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) {
+ now := time.Now().UTC()
+ child := &task.Task{
+ ID: uuid.NewString(),
+ Name: spec.Name,
+ ParentTaskID: c.taskID,
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Model: spec.Model,
+ Instructions: spec.Instructions,
+ MaxBudgetUSD: spec.MaxBudgetUSD,
+ },
+ Priority: task.PriorityNormal,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := c.store.CreateTask(child); err != nil {
+ return "", err
+ }
+ return child.ID, nil
+}
+
+func (c *storeChannel) RecordProgress(_ context.Context, message string) error {
+ payload, _ := json.Marshal(struct {
+ Message string `json:"message"`
+ }{Message: message})
+ return c.store.CreateEvent(&event.Event{
+ TaskID: c.taskID,
+ Kind: event.KindAgentMessage,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ })
+} \ No newline at end of file
diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go
new file mode 100644
index 0000000..250e8eb
--- /dev/null
+++ b/internal/executor/channel_test.go
@@ -0,0 +1,124 @@
+package executor
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+type fakeChannelStore struct {
+ createdTasks []*task.Task
+ createdEvents []*event.Event
+ createTaskErr 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 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)
+ }
+}
+
+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")
+ }
+}
+
+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)
+ }
+}
diff --git a/internal/executor/claude.go b/internal/executor/claude.go
deleted file mode 100644
index 7b70486..0000000
--- a/internal/executor/claude.go
+++ /dev/null
@@ -1,554 +0,0 @@
-package executor
-
-import (
- "context"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "github.com/thepeterstone/claudomator/internal/retry"
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-// ClaudeRunner spawns the `claude` CLI in non-interactive mode.
-type ClaudeRunner struct {
- BinaryPath string // defaults to "claude"
- Logger *slog.Logger
- LogDir string // base directory for execution logs
- APIURL string // base URL of the Claudomator API, passed to subprocesses
-}
-
-// BlockedError is returned by Run when the agent wrote a question file and exited.
-// The pool transitions the task to BLOCKED and stores the question for the user.
-// ExecLogDir returns the log directory for the given execution ID.
-// Implements LogPather so the pool can persist paths before execution starts.
-func (r *ClaudeRunner) ExecLogDir(execID string) string {
- if r.LogDir == "" {
- return ""
- }
- return filepath.Join(r.LogDir, execID)
-}
-
-func (r *ClaudeRunner) binaryPath() string {
- if r.BinaryPath != "" {
- return r.BinaryPath
- }
- return "claude"
-}
-
-// Run executes a claude -p invocation, streaming output to log files.
-// It retries up to 3 times on rate-limit errors using exponential backoff.
-// If the agent writes a question file and exits, Run returns *BlockedError.
-//
-// When project_dir is set and this is not a resume execution, Run clones the
-// project into a temp sandbox, runs the agent there, then merges committed
-// changes back to project_dir. On failure the sandbox is preserved and its
-// path is included in the error.
-func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
- projectDir := t.Agent.ProjectDir
-
- // Validate project_dir exists when set.
- if projectDir != "" {
- if _, err := os.Stat(projectDir); err != nil {
- return fmt.Errorf("project_dir %q: %w", projectDir, err)
- }
- }
-
- // Setup log directory once; retries overwrite the log files.
- logDir := r.ExecLogDir(e.ID)
- if logDir == "" {
- logDir = e.ID // fallback for tests without LogDir set
- }
- if err := os.MkdirAll(logDir, 0700); err != nil {
- return fmt.Errorf("creating log dir: %w", err)
- }
- if e.StdoutPath == "" {
- e.StdoutPath = filepath.Join(logDir, "stdout.log")
- e.StderrPath = filepath.Join(logDir, "stderr.log")
- e.ArtifactDir = logDir
- }
-
- // Pre-assign session ID so we can resume after a BLOCKED state.
- // For resume executions, the claude session continues under the original
- // session ID (the one passed to --resume). Using the new exec's own UUID
- // would cause a second block-and-resume cycle to pass the wrong --resume
- // argument.
- if e.SessionID == "" {
- if e.ResumeSessionID != "" {
- e.SessionID = e.ResumeSessionID
- } else {
- e.SessionID = e.ID // reuse execution UUID as session UUID (both are UUIDs)
- }
- }
-
- // For new (non-resume) executions with a project_dir, clone into a sandbox.
- // Resume executions run in the preserved sandbox (e.SandboxDir) so Claude
- // finds its session files under the same project slug. If no sandbox was
- // preserved (e.g. task had no project_dir), fall back to project_dir.
- var sandboxDir string
- var startHEAD string
- effectiveWorkingDir := projectDir
- if e.ResumeSessionID != "" {
- if e.SandboxDir != "" {
- if _, statErr := os.Stat(e.SandboxDir); statErr == nil {
- effectiveWorkingDir = e.SandboxDir
- } else {
- // Preserved sandbox was cleaned up (e.g. /tmp purge after reboot).
- // Clone a fresh sandbox so the task can run rather than fail immediately.
- r.Logger.Warn("preserved sandbox missing, cloning fresh", "sandbox", e.SandboxDir, "project_dir", projectDir)
- e.SandboxDir = ""
- if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
-
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("fresh sandbox created for resume", "sandbox", sandboxDir, "project_dir", projectDir)
- }
- }
- }
- } else if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
-
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir)
- }
-
- if effectiveWorkingDir != "" {
- // Capture the initial HEAD so we can identify new commits later.
- headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output()
- startHEAD = strings.TrimSpace(string(headOut))
- }
-
- questionFile := filepath.Join(logDir, "question.json")
- args := r.buildArgs(t, e, questionFile)
-
- attempt := 0
- err := retry.RunWithBackoff(ctx, 3, 5*time.Second, func() error {
- if attempt > 0 {
- delay := 5 * time.Second * (1 << (attempt - 1))
- r.Logger.Warn("rate-limited by Claude API, retrying",
- "attempt", attempt,
- "delay", delay,
- )
- }
- attempt++
- return r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e)
- })
- if err != nil {
- if sandboxDir != "" {
- return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir)
- }
- return err
- }
-
- // Check whether the agent left a question before exiting.
- data, readErr := os.ReadFile(questionFile)
- if readErr == nil {
- os.Remove(questionFile) // consumed
- questionJSON := strings.TrimSpace(string(data))
- // If the agent wrote a completion report instead of a real question,
- // extract the text as the summary and fall through to normal completion.
- if isCompletionReport(questionJSON) {
- r.Logger.Info("treating question file as completion report", "taskID", e.TaskID)
- e.Summary = extractQuestionText(questionJSON)
- } else {
- // Preserve sandbox on BLOCKED — agent may have partial work and its
- // Claude session files are stored under the sandbox's project slug.
- // The resume execution must run in the same directory.
- return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir}
- }
- }
-
- // Read agent summary if written.
- summaryFile := filepath.Join(logDir, "summary.txt")
- if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil {
- os.Remove(summaryFile) // consumed
- e.Summary = strings.TrimSpace(string(summaryData))
- }
-
- // Merge sandbox back to project_dir and clean up.
- if sandboxDir != "" {
- if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil {
- return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir)
- }
- }
- return nil
-}
-
-// sandboxCloneSource returns the URL to clone the sandbox from. It prefers a
-// remote named "local" (a local bare repo that accepts pushes cleanly), then
-// falls back to "origin", then to the working copy path itself.
-func sandboxCloneSource(projectDir string) string {
- for _, remote := range []string{"local", "origin"} {
- out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", remote)...).Output()
- if err == nil {
- u := strings.TrimSpace(string(out))
- if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) {
- return u
- }
- }
- }
- return projectDir
-}
-
-// setupSandbox prepares a temporary git clone of projectDir.
-// If projectDir is not a git repo it is initialised with an initial commit first.
-func setupSandbox(projectDir string, logger *slog.Logger) (string, error) {
- // Ensure projectDir is a git repo; initialise if not.
- if err := exec.Command("git", gitSafe("-C", projectDir, "rev-parse", "--git-dir")...).Run(); err != nil {
- cmds := [][]string{
- gitSafe("-C", projectDir, "init"),
- gitSafe("-C", projectDir, "add", "-A"),
- gitSafe("-C", projectDir, "commit", "--allow-empty", "-m", "chore: initial commit"),
- }
- for _, args := range cmds {
- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { //nolint:gosec
- return "", fmt.Errorf("git init %s: %w\n%s", projectDir, err, out)
- }
- }
- }
-
- src := sandboxCloneSource(projectDir)
-
- tempDir, err := os.MkdirTemp("", "claudomator-sandbox-*")
- if err != nil {
- return "", fmt.Errorf("creating sandbox dir: %w", err)
- }
- // git clone requires the target to not exist; remove the placeholder first.
- if err := os.Remove(tempDir); err != nil {
- return "", fmt.Errorf("removing temp dir placeholder: %w", err)
- }
- out, err := exec.Command("git", gitSafe("clone", "--no-hardlinks", src, tempDir)...).CombinedOutput()
- if err != nil {
- return "", fmt.Errorf("git clone: %w\n%s", err, out)
- }
- return tempDir, nil
-}
-
-// teardownSandbox verifies the sandbox is clean and pushes new commits to the
-// canonical bare repo. If the push is rejected because another task pushed
-// concurrently, it fetches and rebases then retries once.
-//
-// The working copy (projectDir) is NOT updated automatically — it is the
-// developer's workspace and is pulled manually. This avoids permission errors
-// from mixed-owner .git/objects directories.
-func teardownSandbox(projectDir, sandboxDir, startHEAD string, logger *slog.Logger, execRecord *storage.Execution) error {
- // Automatically commit uncommitted changes.
- out, err := exec.Command("git", gitSafe("-C", sandboxDir, "status", "--porcelain")...).Output()
- if err != nil {
- return fmt.Errorf("git status: %w", err)
- }
- if len(strings.TrimSpace(string(out))) > 0 {
- logger.Info("autocommitting uncommitted changes", "sandbox", sandboxDir)
-
- // Run build before autocommitting.
- if _, err := os.Stat(filepath.Join(sandboxDir, "Makefile")); err == nil {
- logger.Info("running 'make build' before autocommit", "sandbox", sandboxDir)
- if buildOut, buildErr := exec.Command("make", "-C", sandboxDir, "build").CombinedOutput(); buildErr != nil {
- return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
- }
- } else if _, err := os.Stat(filepath.Join(sandboxDir, "gradlew")); err == nil {
- logger.Info("running './gradlew build' before autocommit", "sandbox", sandboxDir)
- cmd := exec.Command("./gradlew", "build")
- cmd.Dir = sandboxDir
- if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil {
- return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
- }
- } else if _, err := os.Stat(filepath.Join(sandboxDir, "go.mod")); err == nil {
- logger.Info("running 'go build ./...' before autocommit", "sandbox", sandboxDir)
- cmd := exec.Command("go", "build", "./...")
- cmd.Dir = sandboxDir
- if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil {
- return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
- }
- }
-
- cmds := [][]string{
- gitSafe("-C", sandboxDir, "add", "-A"),
- gitSafe("-C", sandboxDir, "commit", "-m", "chore: autocommit uncommitted changes"),
- }
- for _, args := range cmds {
- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
- return fmt.Errorf("autocommit failed (%v): %w\n%s", args, err, out)
- }
- }
- }
-
- // Capture commits before pushing/deleting.
- // Use startHEAD..HEAD to find all commits made during this execution.
- logRange := "origin/HEAD..HEAD"
- if startHEAD != "" && startHEAD != "HEAD" {
- logRange = startHEAD + "..HEAD"
- }
-
- logCmd := exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...)
- logOut, logErr := logCmd.CombinedOutput()
- if logErr == nil {
- lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
- logger.Debug("captured commits", "count", len(lines), "range", logRange)
- for _, line := range lines {
- if line == "" {
- continue
- }
- parts := strings.SplitN(line, "|", 2)
- if len(parts) == 2 {
- execRecord.Commits = append(execRecord.Commits, task.GitCommit{
- Hash: parts[0],
- Message: parts[1],
- })
- }
- }
- } else {
- logger.Warn("failed to capture commits", "err", logErr, "range", logRange, "output", string(logOut))
- }
-
- // Check whether there are any new commits to push.
- ahead, err := exec.Command("git", gitSafe("-C", sandboxDir, "rev-list", "--count", logRange)...).Output()
- if err != nil {
- logger.Warn("could not determine commits ahead of origin; proceeding", "err", err, "range", logRange)
- }
- if strings.TrimSpace(string(ahead)) == "0" {
- os.RemoveAll(sandboxDir)
- return nil
- }
-
- // Push from sandbox → bare repo (sandbox's origin is the bare repo).
- if out, err := exec.Command("git", gitSafe("-C", sandboxDir, "push", "origin", "HEAD")...).CombinedOutput(); err != nil {
- // If rejected due to concurrent push, fetch+rebase and retry once.
- // Use `git pull --rebase` without explicit remote/branch so git uses the
- // configured upstream (set by clone), handling both main- and master-based repos.
- if strings.Contains(string(out), "fetch first") || strings.Contains(string(out), "non-fast-forward") {
- logger.Info("push rejected (concurrent task); rebasing and retrying", "sandbox", sandboxDir)
- if out2, err2 := exec.Command("git", gitSafe("-C", sandboxDir, "pull", "--rebase")...).CombinedOutput(); err2 != nil {
- return fmt.Errorf("git rebase before retry push: %w\n%s", err2, out2)
- }
- // Re-capture commits after rebase (hashes might have changed)
- execRecord.Commits = nil
- logOut, logErr = exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...).Output()
- if logErr == nil {
- lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
- for _, line := range lines {
- parts := strings.SplitN(line, "|", 2)
- if len(parts) == 2 {
- execRecord.Commits = append(execRecord.Commits, task.GitCommit{
- Hash: parts[0],
- Message: parts[1],
- })
- }
- }
- }
-
- if out3, err3 := exec.Command("git", gitSafe("-C", sandboxDir, "push", "origin", "HEAD")...).CombinedOutput(); err3 != nil {
- return fmt.Errorf("git push to origin (after rebase): %w\n%s", err3, out3)
- }
- } else {
- return fmt.Errorf("git push to origin: %w\n%s", err, out)
- }
- }
-
- logger.Info("sandbox pushed to bare repo", "sandbox", sandboxDir)
- os.RemoveAll(sandboxDir)
- return nil
-}
-
-// execOnce runs the claude subprocess once, streaming output to e's log paths.
-func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error {
- cmd := exec.CommandContext(ctx, r.binaryPath(), args...)
- cmd.Env = append(os.Environ(),
- "CLAUDOMATOR_API_URL="+r.APIURL,
- "CLAUDOMATOR_TASK_ID="+e.TaskID,
- "CLAUDOMATOR_PROJECT_DIR="+projectDir,
- "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"),
- "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"),
- )
- // Put the subprocess in its own process group so we can SIGKILL the entire
- // group (MCP servers, bash children, etc.) on cancellation.
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- if workingDir != "" {
- cmd.Dir = workingDir
- }
-
- stdoutFile, err := os.Create(e.StdoutPath)
- if err != nil {
- return fmt.Errorf("creating stdout log: %w", err)
- }
- defer stdoutFile.Close()
-
- stderrFile, err := os.Create(e.StderrPath)
- if err != nil {
- return fmt.Errorf("creating stderr log: %w", err)
- }
- defer stderrFile.Close()
-
- // Use os.Pipe for stdout so we own the read-end lifetime.
- // cmd.StdoutPipe() would add the read-end to closeAfterWait, causing
- // cmd.Wait() to close it before our goroutine finishes reading.
- stdoutR, stdoutW, err := os.Pipe()
- if err != nil {
- return fmt.Errorf("creating stdout pipe: %w", err)
- }
- cmd.Stdout = stdoutW // *os.File — not added to closeAfterStart/Wait
- cmd.Stderr = stderrFile
-
- if err := cmd.Start(); err != nil {
- stdoutW.Close()
- stdoutR.Close()
- return fmt.Errorf("starting claude: %w", err)
- }
- // Close our write-end immediately; the subprocess holds its own copy.
- // The goroutine below gets EOF when the subprocess exits.
- stdoutW.Close()
-
- // killDone is closed when cmd.Wait() returns, stopping the pgid-kill goroutine.
- //
- // Safety: this goroutine cannot block indefinitely. The select has two arms:
- // • ctx.Done() — fires if the caller cancels (e.g. timeout, user cancel).
- // The goroutine sends SIGKILL and exits immediately.
- // • killDone — closed by close(killDone) below, immediately after cmd.Wait()
- // returns. This fires when the process exits for any reason (natural exit,
- // SIGKILL from the ctx arm, or any other signal). The goroutine exits without
- // doing anything.
- //
- // Therefore: for a task that completes normally with a long-lived (non-cancelled)
- // context, the killDone arm fires and the goroutine exits. There is no path where
- // this goroutine outlives execOnce().
- killDone := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- // SIGKILL the entire process group to reap orphan children.
- syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
- case <-killDone:
- }
- }()
-
- // Stream stdout to the log file and parse cost/errors.
- // wg ensures costUSD and streamErr are fully written before we read them after cmd.Wait().
- var costUSD float64
- var streamErr error
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- costUSD, _, streamErr = parseStream(stdoutR, stdoutFile, r.Logger)
- stdoutR.Close()
- }()
-
- waitErr := cmd.Wait()
- close(killDone) // stop the pgid-kill goroutine
- wg.Wait() // drain remaining stdout before reading costUSD/streamErr
-
- e.CostUSD = costUSD
-
- if waitErr != nil {
- if exitErr, ok := waitErr.(*exec.ExitError); ok {
- e.ExitCode = exitErr.ExitCode()
- }
- // If the stream captured a rate-limit or quota message, return it
- // so callers can distinguish it from a generic exit-status failure.
- if retry.IsRateLimitError(streamErr) || isQuotaExhausted(streamErr) {
- return streamErr
- }
- if tail := tailFile(e.StderrPath, 20); tail != "" {
- return fmt.Errorf("claude exited with error: %w\nstderr:\n%s", waitErr, tail)
- }
- return fmt.Errorf("claude exited with error: %w", waitErr)
- }
-
- e.ExitCode = 0
- if streamErr != nil {
- return streamErr
- }
- return nil
-}
-
-func (r *ClaudeRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string {
- // Resume execution: the agent already has context; just deliver the answer.
- if e.ResumeSessionID != "" {
- args := []string{
- "-p", e.ResumeAnswer,
- "--resume", e.ResumeSessionID,
- "--output-format", "stream-json",
- "--verbose",
- }
- permMode := t.Agent.PermissionMode
- if permMode == "" {
- permMode = "bypassPermissions"
- }
- args = append(args, "--permission-mode", permMode)
- if t.Agent.Model != "" {
- args = append(args, "--model", t.Agent.Model)
- }
- return args
- }
-
- instructions := t.Agent.Instructions
- allowedTools := t.Agent.AllowedTools
-
- if !t.Agent.SkipPlanning {
- instructions = withPlanningPreamble(instructions)
- // Ensure Bash is available so the agent can POST subtasks and ask questions.
- hasBash := false
- for _, tool := range allowedTools {
- if tool == "Bash" {
- hasBash = true
- break
- }
- }
- if !hasBash {
- allowedTools = append(allowedTools, "Bash")
- }
- }
-
- args := []string{
- "-p", instructions,
- "--session-id", e.SessionID,
- "--output-format", "stream-json",
- "--verbose",
- }
-
- if t.Agent.Model != "" {
- args = append(args, "--model", t.Agent.Model)
- }
- if t.Agent.MaxBudgetUSD > 0 {
- args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", t.Agent.MaxBudgetUSD))
- }
- // Default to bypassPermissions — claudomator runs tasks unattended, so
- // prompting for write access would always stall execution. Tasks that need
- // a more restrictive mode can set permission_mode explicitly.
- permMode := t.Agent.PermissionMode
- if permMode == "" {
- permMode = "bypassPermissions"
- }
- args = append(args, "--permission-mode", permMode)
- if t.Agent.SystemPromptAppend != "" {
- args = append(args, "--append-system-prompt", t.Agent.SystemPromptAppend)
- }
- for _, tool := range allowedTools {
- args = append(args, "--allowedTools", tool)
- }
- for _, tool := range t.Agent.DisallowedTools {
- args = append(args, "--disallowedTools", tool)
- }
- for _, f := range t.Agent.ContextFiles {
- args = append(args, "--add-dir", f)
- }
- args = append(args, t.Agent.AdditionalArgs...)
-
- return args
-}
-
diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go
deleted file mode 100644
index c01e160..0000000
--- a/internal/executor/claude_test.go
+++ /dev/null
@@ -1,810 +0,0 @@
-package executor
-
-import (
- "context"
- "fmt"
- "io"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
- "time"
-
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-func TestClaudeRunner_BuildArgs_BasicTask(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "fix the bug",
- Model: "sonnet",
- SkipPlanning: true,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- argMap := make(map[string]bool)
- for _, a := range args {
- argMap[a] = true
- }
- for _, want := range []string{"-p", "fix the bug", "--output-format", "stream-json", "--verbose", "--model", "sonnet"} {
- if !argMap[want] {
- t.Errorf("missing arg %q in %v", want, args)
- }
- }
-}
-
-func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "implement feature",
- Model: "opus",
- MaxBudgetUSD: 5.0,
- PermissionMode: "bypassPermissions",
- SystemPromptAppend: "Follow TDD",
- AllowedTools: []string{"Bash", "Edit"},
- DisallowedTools: []string{"Write"},
- ContextFiles: []string{"/src"},
- AdditionalArgs: []string{"--verbose"},
- SkipPlanning: true,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- // Check key args are present.
- argMap := make(map[string]bool)
- for _, a := range args {
- argMap[a] = true
- }
-
- requiredArgs := []string{
- "-p", "implement feature", "--output-format", "stream-json",
- "--model", "opus", "--max-budget-usd", "5.00",
- "--permission-mode", "bypassPermissions",
- "--append-system-prompt", "Follow TDD",
- "--allowedTools", "Bash", "Edit",
- "--disallowedTools", "Write",
- "--add-dir", "/src",
- "--verbose",
- }
- for _, req := range requiredArgs {
- if !argMap[req] {
- t.Errorf("missing arg %q in %v", req, args)
- }
- }
-}
-
-func TestClaudeRunner_BuildArgs_DefaultsToBypassPermissions(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "do work",
- SkipPlanning: true,
- // PermissionMode intentionally not set
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- found := false
- for i, a := range args {
- if a == "--permission-mode" && i+1 < len(args) && args[i+1] == "bypassPermissions" {
- found = true
- }
- }
- if !found {
- t.Errorf("expected --permission-mode bypassPermissions when PermissionMode is empty, args: %v", args)
- }
-}
-
-func TestClaudeRunner_BuildArgs_RespectsExplicitPermissionMode(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "do work",
- PermissionMode: "default",
- SkipPlanning: true,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- for i, a := range args {
- if a == "--permission-mode" && i+1 < len(args) {
- if args[i+1] != "default" {
- t.Errorf("expected --permission-mode default, got %q", args[i+1])
- }
- return
- }
- }
- t.Errorf("--permission-mode flag not found in args: %v", args)
-}
-
-func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "do something",
- SkipPlanning: true,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- found := false
- for _, a := range args {
- if a == "--verbose" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("--verbose missing from args: %v", args)
- }
-}
-
-func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "fix the bug",
- SkipPlanning: false,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- // The -p value should start with the preamble and end with the original instructions.
- if len(args) < 2 || args[0] != "-p" {
- t.Fatalf("expected -p as first arg, got: %v", args)
- }
- if !strings.HasPrefix(args[1], "## Runtime Environment") {
- t.Errorf("instructions should start with planning preamble, got prefix: %q", args[1][:min(len(args[1]), 20)])
- }
- if !strings.Contains(args[1], "$CLAUDOMATOR_PROJECT_DIR") {
- t.Errorf("preamble should mention $CLAUDOMATOR_PROJECT_DIR")
- }
- if !strings.HasSuffix(args[1], "fix the bug") {
- t.Errorf("instructions should end with original instructions")
- }
-}
-
-func TestClaudeRunner_BuildArgs_PreambleAddsBash(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "do work",
- AllowedTools: []string{"Read"},
- SkipPlanning: false,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- // Bash should be appended to allowed tools.
- foundBash := false
- for i, a := range args {
- if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" {
- foundBash = true
- }
- }
- if !foundBash {
- t.Errorf("Bash should be added to --allowedTools when preamble is active: %v", args)
- }
-}
-
-func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) {
- r := &ClaudeRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "do work",
- AllowedTools: []string{"Bash", "Read"},
- SkipPlanning: false,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- // Count Bash occurrences in --allowedTools values.
- bashCount := 0
- for i, a := range args {
- if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" {
- bashCount++
- }
- }
- if bashCount != 1 {
- t.Errorf("Bash should appear exactly once in --allowedTools, got %d: %v", bashCount, args)
- }
-}
-
-// TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession verifies that when a
-// resume execution is itself blocked again, the stored SessionID is the original
-// resumed session, not the new execution's own UUID. Without this, a second
-// block-and-resume cycle passes the wrong --resume session ID and fails.
-func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) {
- logDir := t.TempDir()
- r := &ClaudeRunner{
- BinaryPath: "true", // exits 0, no output
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- Instructions: "continue",
- SkipPlanning: true,
- },
- }
- exec := &storage.Execution{
- ID: "resume-exec-uuid",
- TaskID: "task-1",
- ResumeSessionID: "original-session-uuid",
- ResumeAnswer: "yes",
- }
-
- // Run completes successfully (binary is "true").
- _ = r.Run(context.Background(), tk, exec)
-
- // SessionID must be the original session (ResumeSessionID), not the new
- // exec's own ID. If it were exec.ID, a second blocked-then-resumed cycle
- // would use the wrong --resume argument and fail.
- if exec.SessionID != "original-session-uuid" {
- t.Errorf("SessionID after resume Run: want %q, got %q", "original-session-uuid", exec.SessionID)
- }
-}
-
-func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) {
- r := &ClaudeRunner{
- BinaryPath: "true", // would succeed if it ran
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: t.TempDir(),
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- ProjectDir: "/nonexistent/path/does/not/exist",
- SkipPlanning: true,
- },
- }
- exec := &storage.Execution{ID: "test-exec"}
-
- err := r.Run(context.Background(), tk, exec)
-
- if err == nil {
- t.Fatal("expected error for inaccessible working_dir, got nil")
- }
- if !strings.Contains(err.Error(), "project_dir") {
- t.Errorf("expected 'project_dir' in error, got: %v", err)
- }
-}
-
-func TestClaudeRunner_BinaryPath_Default(t *testing.T) {
- r := &ClaudeRunner{}
- if r.binaryPath() != "claude" {
- t.Errorf("want 'claude', got %q", r.binaryPath())
- }
-}
-
-func TestClaudeRunner_BinaryPath_Custom(t *testing.T) {
- r := &ClaudeRunner{BinaryPath: "/usr/local/bin/claude"}
- if r.binaryPath() != "/usr/local/bin/claude" {
- t.Errorf("want custom path, got %q", r.binaryPath())
- }
-}
-
-// TestExecOnce_NoGoroutineLeak_OnNaturalExit verifies that execOnce does not
-// leave behind any goroutines when the subprocess exits normally (no context
-// cancellation). Both the pgid-kill goroutine and the parseStream goroutine
-// must have exited before execOnce returns.
-func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) {
- logDir := t.TempDir()
- r := &ClaudeRunner{
- BinaryPath: "true", // exits immediately with status 0, produces no output
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- e := &storage.Execution{
- ID: "goroutine-leak-test",
- TaskID: "task-id",
- StdoutPath: filepath.Join(logDir, "stdout.log"),
- StderrPath: filepath.Join(logDir, "stderr.log"),
- ArtifactDir: logDir,
- }
-
- // Let any goroutines from test infrastructure settle before sampling.
- runtime.Gosched()
- baseline := runtime.NumGoroutine()
-
- if err := r.execOnce(context.Background(), []string{}, "", "", e); err != nil {
- t.Fatalf("execOnce failed: %v", err)
- }
-
- // Give the scheduler a moment to let any leaked goroutines actually exit.
- // In correct code the goroutines exit before execOnce returns, so this is
- // just a safety buffer for the scheduler.
- time.Sleep(10 * time.Millisecond)
- runtime.Gosched()
-
- after := runtime.NumGoroutine()
- if after > baseline {
- t.Errorf("goroutine leak: %d goroutines before execOnce, %d after (leaked %d)",
- baseline, after, after-baseline)
- }
-}
-
-// initGitRepo creates a git repo in dir with one commit so it is clonable.
-func initGitRepo(t *testing.T, dir string) {
- t.Helper()
- cmds := [][]string{
- {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "init", "-b", "main"},
- {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.email", "test@test"},
- {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.name", "test"},
- }
- for _, args := range cmds {
- if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
- t.Fatalf("%v: %v\n%s", args, err, out)
- }
- }
- if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil {
- t.Fatal(err)
- }
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "add", ".").CombinedOutput(); err != nil {
- t.Fatalf("git add: %v\n%s", err, out)
- }
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil {
- t.Fatalf("git commit: %v\n%s", err, out)
- }
-}
-
-func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) {
- dir := t.TempDir()
- initGitRepo(t, dir)
- // Add a "local" remote pointing to a bare repo.
- bare := t.TempDir()
- exec.Command("git", "init", "--bare", bare).Run()
- exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run()
- exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run()
-
- got := sandboxCloneSource(dir)
- if got != bare {
- t.Errorf("expected bare repo path %q, got %q", bare, got)
- }
-}
-
-func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) {
- dir := t.TempDir()
- initGitRepo(t, dir)
- // sandboxCloneSource intentionally filters to local-FS remotes (so
- // `git clone <src>` doesn't go over the network). Use a local path
- // for origin to verify the fallback semantics.
- originURL := t.TempDir()
- exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run()
-
- got := sandboxCloneSource(dir)
- if got != originURL {
- t.Errorf("expected origin URL %q, got %q", originURL, got)
- }
-}
-
-func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) {
- dir := t.TempDir()
- initGitRepo(t, dir)
- // No remotes configured.
- got := sandboxCloneSource(dir)
- if got != dir {
- t.Errorf("expected projectDir %q (no remotes), got %q", dir, got)
- }
-}
-
-func TestSetupSandbox_ClonesGitRepo(t *testing.T) {
- src := t.TempDir()
- initGitRepo(t, src)
-
- sandbox, err := setupSandbox(src, slog.Default())
- if err != nil {
- t.Fatalf("setupSandbox: %v", err)
- }
- t.Cleanup(func() { os.RemoveAll(sandbox) })
-
- // Force sandbox to master if it cloned as main
- exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run()
-
- // Debug sandbox
- logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput()
- fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut))
-
- // Verify sandbox is a git repo with at least one commit.
- out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output()
- if err != nil {
- t.Fatalf("git log in sandbox: %v", err)
- }
- if len(strings.TrimSpace(string(out))) == 0 {
- t.Error("expected at least one commit in sandbox, got empty log")
- }
-}
-
-func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) {
- // A plain directory (not a git repo) should be initialised then cloned.
- src := t.TempDir()
-
- sandbox, err := setupSandbox(src, slog.Default())
- if err != nil {
- t.Fatalf("setupSandbox on plain dir: %v", err)
- }
- t.Cleanup(func() { os.RemoveAll(sandbox) })
-
- if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil {
- t.Errorf("sandbox should be a git repo: %v", err)
- }
-}
-
-func TestTeardownSandbox_AutocommitsChanges(t *testing.T) {
- // Create a bare repo as origin so push succeeds.
- bare := t.TempDir()
- if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
- t.Fatalf("git init bare: %v\n%s", err, out)
- }
-
- // Create a sandbox directly.
- sandbox := t.TempDir()
- initGitRepo(t, sandbox)
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
- t.Fatalf("git remote add: %v\n%s", err, out)
- }
- // Initial push to establish origin/main
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil {
- t.Fatalf("git push initial: %v\n%s", err, out)
- }
-
- // Capture startHEAD
- headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
- if err != nil {
- t.Fatalf("rev-parse HEAD: %v", err)
- }
- startHEAD := strings.TrimSpace(string(headOut))
-
- // Leave an uncommitted file in the sandbox.
- if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil {
- t.Fatal(err)
- }
-
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
- execRecord := &storage.Execution{}
-
- err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
- if err != nil {
- t.Fatalf("expected autocommit to succeed, got error: %v", err)
- }
-
- // Sandbox should be removed after successful autocommit and push.
- if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
- t.Error("sandbox should have been removed after successful autocommit and push")
- }
-
- // Verify the commit exists in the bare repo.
- out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output()
- if err != nil {
- t.Fatalf("git log in bare repo: %v", err)
- }
- if !strings.Contains(string(out), "chore: autocommit uncommitted changes") {
- t.Errorf("expected autocommit message in log, got: %q", string(out))
- }
-
- // Verify the commit was captured in execRecord.
- if len(execRecord.Commits) == 0 {
- t.Error("expected at least one commit in execRecord")
- } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") {
- t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message)
- }
-}
-
-func TestTeardownSandbox_BuildFailure_BlocksAutocommit(t *testing.T) {
- bare := t.TempDir()
- if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
- t.Fatalf("git init bare: %v\n%s", err, out)
- }
-
- sandbox := t.TempDir()
- initGitRepo(t, sandbox)
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
- t.Fatalf("git remote add: %v\n%s", err, out)
- }
-
- // Capture startHEAD
- headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
- if err != nil {
- t.Fatalf("rev-parse HEAD: %v", err)
- }
- startHEAD := strings.TrimSpace(string(headOut))
-
- // Leave an uncommitted file.
- if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil {
- t.Fatal(err)
- }
-
- // Add a failing Makefile.
- makefile := "build:\n\t@echo 'build failed'\n\texit 1\n"
- if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil {
- t.Fatal(err)
- }
-
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- execRecord := &storage.Execution{}
-
- err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
- if err == nil {
- t.Error("expected teardown to fail due to build failure, but it succeeded")
- } else if !strings.Contains(err.Error(), "build failed before autocommit") {
- t.Errorf("expected build failure error message, got: %v", err)
- }
-
- // Sandbox should NOT be removed if teardown failed.
- if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) {
- t.Error("sandbox should have been preserved after build failure")
- }
-
- // Verify no new commit in bare repo.
- out, err := exec.Command("git", "-C", bare, "log", "HEAD").CombinedOutput()
- if strings.Contains(string(out), "chore: autocommit uncommitted changes") {
- t.Error("autocommit should not have been pushed after build failure")
- }
-}
-
-func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) {
- bare := t.TempDir()
- if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
- t.Fatalf("git init bare: %v\n%s", err, out)
- }
-
- sandbox := t.TempDir()
- initGitRepo(t, sandbox)
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
- t.Fatalf("git remote add: %v\n%s", err, out)
- }
-
- // Capture startHEAD
- headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
- if err != nil {
- t.Fatalf("rev-parse HEAD: %v", err)
- }
- startHEAD := strings.TrimSpace(string(headOut))
-
- // Leave an uncommitted file.
- if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil {
- t.Fatal(err)
- }
-
- // Add a successful Makefile.
- makefile := "build:\n\t@echo 'build succeeded'\n"
- if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil {
- t.Fatal(err)
- }
-
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- execRecord := &storage.Execution{}
-
- err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
- if err != nil {
- t.Fatalf("expected teardown to succeed after build success, got error: %v", err)
- }
-
- // Sandbox should be removed after success.
- if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
- t.Error("sandbox should have been removed after successful build and autocommit")
- }
-
- // Verify new commit in bare repo.
- out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output()
- if err != nil {
- t.Fatalf("git log in bare repo: %v", err)
- }
- if !strings.Contains(string(out), "chore: autocommit uncommitted changes") {
- t.Errorf("expected autocommit message in log, got: %q", string(out))
- }
-}
-
-
-func TestTeardownSandbox_CapturesExplicitCommits(t *testing.T) {
- bare := t.TempDir()
- if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
- t.Fatalf("git init bare: %v\n%s", err, out)
- }
-
- sandbox := t.TempDir()
- initGitRepo(t, sandbox)
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
- t.Fatalf("git remote add: %v\n%s", err, out)
- }
- if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil {
- t.Fatalf("git push initial: %v\n%s", err, out)
- }
-
- headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
- if err != nil {
- t.Fatalf("rev-parse HEAD: %v", err)
- }
- startHEAD := strings.TrimSpace(string(headOut))
-
- // Simulate Claude explicitly committing changes.
- if err := os.WriteFile(filepath.Join(sandbox, "work.txt"), []byte("done"), 0644); err != nil {
- t.Fatal(err)
- }
- for _, args := range [][]string{
- {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "add", "-A"},
- {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "commit", "-m", "feat: implement the feature"},
- } {
- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
- t.Fatalf("git %v: %v\n%s", args, err, out)
- }
- }
-
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- execRecord := &storage.Execution{}
-
- if err := teardownSandbox("", sandbox, startHEAD, logger, execRecord); err != nil {
- t.Fatalf("teardownSandbox: %v", err)
- }
-
- if len(execRecord.Commits) == 0 {
- t.Fatal("expected commits to be captured in execRecord")
- }
- if !strings.Contains(execRecord.Commits[0].Message, "feat: implement the feature") {
- t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message)
- }
- if execRecord.Commits[0].Hash == "" {
- t.Error("commit hash should not be empty")
- }
-}
-
-func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) {
- src := t.TempDir()
- initGitRepo(t, src)
- sandbox, err := setupSandbox(src, slog.Default())
- if err != nil {
- t.Fatalf("setupSandbox: %v", err)
- }
-
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- execRecord := &storage.Execution{}
-
- headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output()
- startHEAD := strings.TrimSpace(string(headOut))
-
- // Sandbox has no new commits beyond origin; teardown should succeed and remove it.
- if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil {
- t.Fatalf("teardownSandbox: %v", err)
- }
- if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
- t.Error("sandbox should have been removed after clean teardown")
- os.RemoveAll(sandbox)
- }
-}
-
-
-// TestClaudeRunner_Run_ResumeUsesStoredSandboxDir verifies that when a resume
-// execution has SandboxDir set, the runner uses that directory (not project_dir)
-// as the working directory, so Claude finds its session files there.
-func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) {
- logDir := t.TempDir()
- sandboxDir := t.TempDir()
- cwdFile := filepath.Join(logDir, "cwd.txt")
-
- // Use a script that writes its working directory to a file in logDir (stable path).
- scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh")
- script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &ClaudeRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- ProjectDir: sandboxDir, // must exist; resume overrides it with SandboxDir anyway
- SkipPlanning: true,
- },
- }
- exec := &storage.Execution{
- ID: "resume-exec-uuid",
- TaskID: "task-1",
- ResumeSessionID: "original-session",
- ResumeAnswer: "yes",
- SandboxDir: sandboxDir,
- }
-
- _ = r.Run(context.Background(), tk, exec)
-
- got, err := os.ReadFile(cwdFile)
- if err != nil {
- t.Fatalf("cwd file not written: %v", err)
- }
- // The runner should have executed claude in sandboxDir, not in project_dir.
- if string(got) != sandboxDir {
- t.Errorf("resume working dir: want %q, got %q", sandboxDir, string(got))
- }
-}
-
-func TestClaudeRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) {
- logDir := t.TempDir()
- projectDir := t.TempDir()
- initGitRepo(t, projectDir)
-
- cwdFile := filepath.Join(logDir, "cwd.txt")
- scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh")
- script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &ClaudeRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "claude",
- ProjectDir: projectDir,
- SkipPlanning: true,
- },
- }
- // Point to a sandbox that no longer exists (e.g. /tmp was purged).
- staleSandbox := filepath.Join(t.TempDir(), "gone")
- e := &storage.Execution{
- ID: "resume-exec-2",
- TaskID: "task-2",
- ResumeSessionID: "session-abc",
- ResumeAnswer: "ok",
- SandboxDir: staleSandbox,
- }
-
- if err := r.Run(context.Background(), tk, e); err != nil {
- t.Fatalf("Run with stale sandbox: %v", err)
- }
-
- got, err := os.ReadFile(cwdFile)
- if err != nil {
- t.Fatalf("cwd file not written: %v", err)
- }
- // Should have run in a fresh sandbox (not the stale path, not the raw projectDir).
- // The sandbox is removed after teardown, so we only check what it wasn't.
- cwd := string(got)
- if cwd == staleSandbox {
- t.Error("ran in stale sandbox dir that doesn't exist")
- }
- if cwd == projectDir {
- t.Error("ran directly in project_dir; expected a fresh sandbox clone")
- }
- // cwd should look like a claudomator sandbox path.
- if !strings.Contains(cwd, "claudomator-sandbox-") {
- t.Errorf("expected sandbox path, got %q", cwd)
- }
-}
-
-func TestTailFile_MissingFile_ReturnsEmpty(t *testing.T) {
- got := tailFile("/nonexistent/path/file.log", 10)
- if got != "" {
- t.Errorf("want empty string for missing file, got %q", got)
- }
-}
-
diff --git a/internal/executor/container.go b/internal/executor/container.go
index 23e35b3..39f737a 100644
--- a/internal/executor/container.go
+++ b/internal/executor/container.go
@@ -2,6 +2,7 @@ package executor
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -29,6 +30,9 @@ type ContainerRunner struct {
ClaudeConfigDir string // host path to ~/.claude; mounted into container for auth credentials
CredentialSyncCmd string // optional path to sync-credentials script for auth-error auto-recovery
Store Store // optional; used to look up stories and projects for story-aware cloning
+ // Registry mints the per-task MCP token; when set, the agent gets an
+ // mcp-config pointing at the host agent MCP server.
+ Registry *Registry
// Command allows mocking exec.CommandContext for tests.
Command func(ctx context.Context, name string, arg ...string) *exec.Cmd
}
@@ -100,7 +104,7 @@ func (r *ContainerRunner) ensureStoryBranch(ctx context.Context, remoteURL, bran
return nil
}
-func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
var err error
repoURL := t.RepositoryURL
if repoURL == "" {
@@ -237,7 +241,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
}
// Run container (with auth retry on failure).
- runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch)
+ runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch)
if runErr != nil && isAuthError(runErr) && r.CredentialSyncCmd != "" {
r.Logger.Warn("auth failure detected, syncing credentials and retrying once", "taskID", t.ID)
syncOut, syncErr := r.command(ctx, r.CredentialSyncCmd).CombinedOutput()
@@ -251,7 +255,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
if srcData, readErr := os.ReadFile(filepath.Join(r.ClaudeConfigDir, ".claude.json")); readErr == nil {
_ = os.WriteFile(filepath.Join(agentHome, ".claude.json"), srcData, 0644)
}
- runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch)
+ runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch)
}
if runErr == nil {
@@ -267,7 +271,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
// runContainer runs the docker container for the given task and handles log setup,
// environment files, instructions, and post-execution git operations.
-func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string) error {
+func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string, ch AgentChannel) error {
repoURL := t.RepositoryURL
image := t.Agent.ContainerImage
@@ -311,14 +315,39 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto
return fmt.Errorf("writing env file: %w", err)
}
- // Inject custom instructions via file to avoid CLI length limits
+ // Per-task MCP back-channel: mint a scoped token bound to this run's channel
+ // and point the agent at the host agent MCP server. Claude reads an
+ // --mcp-config file; the gemini CLI auto-discovers servers from its
+ // ~/.gemini/settings.json (agentHome is the container's $HOME).
+ mcpEnabled := false
+ if r.Registry != nil {
+ token, mintErr := r.Registry.Mint(ch)
+ if mintErr != nil {
+ return fmt.Errorf("minting agent mcp token: %w", mintErr)
+ }
+ defer r.Registry.Revoke(token)
+ mcpURL := strings.TrimRight(strings.ReplaceAll(r.APIURL, "localhost", "host.docker.internal"), "/") + "/mcp"
+ if t.Agent.Type == "gemini" {
+ if err := writeGeminiMCPSettings(agentHome, mcpURL, token); err != nil {
+ return fmt.Errorf("writing gemini mcp settings: %w", err)
+ }
+ } else if err := writeMCPConfig(workspace, mcpURL, token); err != nil {
+ return fmt.Errorf("writing mcp config: %w", err)
+ }
+ mcpEnabled = true
+ }
+
+ // Inject custom instructions via file to avoid CLI length limits. When the
+ // MCP back-channel is active, prepend the planning preamble that points the
+ // agent at the ask_user/report_summary/spawn_subtask/record_progress tools.
+ instructions := buildAgentInstructions(t, mcpEnabled)
instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt")
- if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0644); err != nil {
+ if err := os.WriteFile(instructionsFile, []byte(instructions), 0644); err != nil {
return fmt.Errorf("writing instructions: %w", err)
}
args := r.buildDockerArgs(workspace, agentHome, e.TaskID)
- innerCmd := r.buildInnerCmd(t, e, isResume)
+ innerCmd := r.buildInnerCmd(t, e, isResume, mcpEnabled)
fullArgs := append(args, image)
fullArgs = append(fullArgs, innerCmd...)
@@ -375,31 +404,13 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto
e.SessionID = sessionID
}
- // Check whether the agent left a question before exiting.
- questionFile := filepath.Join(logDir, "question.json")
- if data, readErr := os.ReadFile(questionFile); readErr == nil {
- os.Remove(questionFile) // consumed
- questionJSON := strings.TrimSpace(string(data))
- if isCompletionReport(questionJSON) {
- r.Logger.Info("treating question file as completion report", "taskID", e.TaskID)
- e.Summary = extractQuestionText(questionJSON)
- } else {
- if e.SessionID == "" {
- r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID)
- }
- return &BlockedError{
- QuestionJSON: questionJSON,
- SessionID: e.SessionID,
- SandboxDir: workspace,
- }
+ // MCP transport: if the agent called ask_user, the question is buffered on
+ // the channel. Block so the task resumes with the user's answer.
+ if q, blocked := channelPendingQuestion(ch); blocked {
+ if e.SessionID == "" {
+ r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID)
}
- }
-
- // Read agent summary if written.
- summaryFile := filepath.Join(logDir, "summary.txt")
- if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil {
- os.Remove(summaryFile) // consumed
- e.Summary = strings.TrimSpace(string(summaryData))
+ return &BlockedError{QuestionJSON: q, SessionID: e.SessionID, SandboxDir: workspace}
}
// 5. Post-execution: push changes if successful
@@ -467,6 +478,10 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string)
"-w", "/workspace",
"--env-file", hostEnvFile,
"-e", "HOME=/home/agent",
+ // The container is an isolated sandbox; IS_SANDBOX=1 lets the claude CLI
+ // honor --permission-mode bypassPermissions even when the agent runs as
+ // root (buildDockerArgs maps --user to the host uid, which may be 0).
+ "-e", "IS_SANDBOX=1",
"-e", "CLAUDOMATOR_API_URL=" + apiURL,
"-e", "CLAUDOMATOR_TASK_ID=" + taskID,
"-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir,
@@ -477,7 +492,71 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string)
return args
}
-func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume bool) []string {
+// mcpConfigContainerPath is where the agent mcp-config is mounted in-container
+// (the workspace is bind-mounted at /workspace).
+const mcpConfigContainerPath = "/workspace/.claudomator-mcp.json"
+
+// buildAgentInstructions returns the agent's instructions, prepending the
+// MCP-tool planning preamble when the back-channel is active and planning is
+// not skipped.
+func buildAgentInstructions(t *task.Task, mcpEnabled bool) string {
+ instructions := t.Agent.Instructions
+ if mcpEnabled && !t.Agent.SkipPlanning {
+ instructions = withPlanningPreamble(instructions)
+ }
+ return instructions
+}
+
+// writeMCPConfig writes a claude CLI mcp-config that registers the per-task
+// agent MCP server over HTTP with a bearer token.
+func writeMCPConfig(workspace, mcpURL, token string) error {
+ cfg := map[string]any{
+ "mcpServers": map[string]any{
+ "claudomator": map[string]any{
+ "type": "http",
+ "url": mcpURL,
+ "headers": map[string]string{"Authorization": "Bearer " + token},
+ },
+ },
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(workspace, ".claudomator-mcp.json"), data, 0600)
+}
+
+// writeGeminiMCPSettings registers the per-task agent MCP server in the gemini
+// CLI's user settings (agentHome/.gemini/settings.json, which is $HOME/.gemini
+// in-container). The gemini CLI discovers MCP servers from this file rather than
+// a command-line flag; httpUrl selects the streamable-HTTP transport and headers
+// carry the bearer token.
+//
+// NOTE: the on-disk schema follows the gemini-cli mcpServers format, but whether
+// the CLI actually invokes these tools in non-interactive (-p) mode has not been
+// verified against a live gemini binary — confirm with a spike before relying on
+// gemini tool-use in production.
+func writeGeminiMCPSettings(agentHome, mcpURL, token string) error {
+ cfg := map[string]any{
+ "mcpServers": map[string]any{
+ "claudomator": map[string]any{
+ "httpUrl": mcpURL,
+ "headers": map[string]string{"Authorization": "Bearer " + token},
+ },
+ },
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Join(agentHome, ".gemini")
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(dir, "settings.json"), data, 0600)
+}
+
+func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume, mcpEnabled bool) []string {
// Claude CLI uses -p for prompt text. To pass a file, we use a shell to cat it.
// We use a shell variable to capture the expansion to avoid quoting issues with instructions contents.
// The outer single quotes around the sh -c argument prevent host-side expansion.
@@ -501,6 +580,9 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe
if isResume && e.ResumeSessionID != "" {
claudeCmd.WriteString(fmt.Sprintf(" --resume %s", e.ResumeSessionID))
}
+ if mcpEnabled {
+ claudeCmd.WriteString(" --mcp-config " + mcpConfigContainerPath)
+ }
claudeCmd.WriteString(" --output-format stream-json --verbose --permission-mode bypassPermissions")
return []string{"sh", "-c", claudeCmd.String()}
@@ -511,6 +593,7 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe
var scaffoldPrefixes = []string{
".claudomator-env",
".claudomator-instructions.txt",
+ ".claudomator-mcp.json",
".agent-home",
}
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index 54b4689..dec666e 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -2,6 +2,7 @@ package executor
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
@@ -36,6 +37,7 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) {
"-w", "/workspace",
"--env-file", "/tmp/ws/.claudomator-env",
"-e", "HOME=/home/agent",
+ "-e", "IS_SANDBOX=1",
"-e", "CLAUDOMATOR_API_URL=http://host.docker.internal:8484",
"-e", "CLAUDOMATOR_TASK_ID=task-123",
"-e", "CLAUDOMATOR_DROP_DIR=/data/drops",
@@ -53,13 +55,96 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) {
}
}
+func TestBuildAgentInstructions(t *testing.T) {
+ t.Run("mcp enabled prepends preamble", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}}
+ got := buildAgentInstructions(tk, true)
+ if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "do the thing") {
+ t.Errorf("expected preamble + instructions, got %q", got)
+ }
+ })
+ t.Run("mcp disabled keeps raw instructions", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}}
+ if got := buildAgentInstructions(tk, false); got != "do the thing" {
+ t.Errorf("expected raw instructions, got %q", got)
+ }
+ })
+ t.Run("skip planning keeps raw instructions even with mcp", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing", SkipPlanning: true}}
+ if got := buildAgentInstructions(tk, true); got != "do the thing" {
+ t.Errorf("expected raw instructions when skip_planning, got %q", got)
+ }
+ })
+}
+
+func TestWriteMCPConfig(t *testing.T) {
+ dir := t.TempDir()
+ if err := writeMCPConfig(dir, "http://host.docker.internal:8484/mcp", "tok-abc"); err != nil {
+ t.Fatalf("writeMCPConfig: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, ".claudomator-mcp.json"))
+ if err != nil {
+ t.Fatalf("read config: %v", err)
+ }
+ var parsed struct {
+ MCPServers map[string]struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers"`
+ } `json:"mcpServers"`
+ }
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("config is not valid JSON: %v", err)
+ }
+ srv, ok := parsed.MCPServers["claudomator"]
+ if !ok {
+ t.Fatal("expected claudomator server entry")
+ }
+ if srv.Type != "http" || srv.URL != "http://host.docker.internal:8484/mcp" {
+ t.Errorf("unexpected server config: %+v", srv)
+ }
+ if srv.Headers["Authorization"] != "Bearer tok-abc" {
+ t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"])
+ }
+}
+
+func TestWriteGeminiMCPSettings(t *testing.T) {
+ agentHome := t.TempDir()
+ if err := writeGeminiMCPSettings(agentHome, "http://host.docker.internal:8484/mcp", "tok-xyz"); err != nil {
+ t.Fatalf("writeGeminiMCPSettings: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(agentHome, ".gemini", "settings.json"))
+ if err != nil {
+ t.Fatalf("read settings: %v", err)
+ }
+ var parsed struct {
+ MCPServers map[string]struct {
+ HTTPURL string `json:"httpUrl"`
+ Headers map[string]string `json:"headers"`
+ } `json:"mcpServers"`
+ }
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("settings is not valid JSON: %v", err)
+ }
+ srv, ok := parsed.MCPServers["claudomator"]
+ if !ok {
+ t.Fatal("expected claudomator server entry")
+ }
+ if srv.HTTPURL != "http://host.docker.internal:8484/mcp" {
+ t.Errorf("unexpected httpUrl: %q", srv.HTTPURL)
+ }
+ if srv.Headers["Authorization"] != "Bearer tok-xyz" {
+ t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"])
+ }
+}
+
func TestContainerRunner_BuildInnerCmd(t *testing.T) {
runner := &ContainerRunner{}
t.Run("claude-fresh", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
exec := &storage.Execution{}
- cmd := runner.buildInnerCmd(tk, exec, false)
+ cmd := runner.buildInnerCmd(tk, exec, false, false)
cmdStr := strings.Join(cmd, " ")
if strings.Contains(cmdStr, "--resume") {
@@ -73,7 +158,7 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
t.Run("claude-resume", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
exec := &storage.Execution{ResumeSessionID: "orig-session-123"}
- cmd := runner.buildInnerCmd(tk, exec, true)
+ cmd := runner.buildInnerCmd(tk, exec, true, false)
cmdStr := strings.Join(cmd, " ")
if !strings.Contains(cmdStr, "--resume orig-session-123") {
@@ -81,10 +166,26 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
}
})
+ t.Run("claude-mcp-enabled", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
+ cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, true), " ")
+ if !strings.Contains(cmdStr, "--mcp-config "+mcpConfigContainerPath) {
+ t.Errorf("expected --mcp-config flag when MCP enabled, got %q", cmdStr)
+ }
+ })
+
+ t.Run("claude-mcp-disabled", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
+ cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, false), " ")
+ if strings.Contains(cmdStr, "--mcp-config") {
+ t.Errorf("did not expect --mcp-config flag when MCP disabled, got %q", cmdStr)
+ }
+ })
+
t.Run("gemini", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}}
exec := &storage.Execution{}
- cmd := runner.buildInnerCmd(tk, exec, false)
+ cmd := runner.buildInnerCmd(tk, exec, false, false)
cmdStr := strings.Join(cmd, " ")
if !strings.Contains(cmdStr, "gemini -p \"$INST\"") {
@@ -99,13 +200,13 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
}
tkClaude := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
- cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false)
+ cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false, false)
if !strings.Contains(strings.Join(cmdClaude, " "), "/usr/bin/claude-v2 -p") {
t.Errorf("expected custom claude binary, got %q", cmdClaude)
}
tkGemini := &task.Task{Agent: task.AgentConfig{Type: "gemini"}}
- cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false)
+ cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false, false)
if !strings.Contains(strings.Join(cmdGemini, " "), "/usr/local/bin/gemini-pro -p") {
t.Errorf("expected custom gemini binary, got %q", cmdGemini)
}
@@ -139,7 +240,7 @@ func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) {
}
exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"}
- err := runner.Run(context.Background(), tk, exec)
+ err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to mocked docker failure")
}
@@ -168,48 +269,6 @@ func TestBlockedError_IncludesSandboxDir(t *testing.T) {
}
}
-func TestIsCompletionReport(t *testing.T) {
- tests := []struct {
- name string
- json string
- expected bool
- }{
- {
- name: "real question with options",
- json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`,
- expected: false,
- },
- {
- name: "real question no options",
- json: `{"text": "Which approach do you prefer?"}`,
- expected: false,
- },
- {
- name: "completion report no options no question mark",
- json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`,
- expected: true,
- },
- {
- name: "completion report with empty options",
- json: `{"text": "Feature implemented and committed.", "options": []}`,
- expected: true,
- },
- {
- name: "invalid json treated as not a report",
- json: `not json`,
- expected: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := isCompletionReport(tt.json)
- if got != tt.expected {
- t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected)
- }
- })
- }
-}
-
func TestTailFile_ReturnsLastNLines(t *testing.T) {
f, err := os.CreateTemp("", "tailfile-*")
if err != nil {
@@ -378,7 +437,7 @@ func TestContainerRunner_MissingCredentials_FailsFast(t *testing.T) {
}
e := &storage.Execution{ID: "test-exec", TaskID: "test-missing-creds"}
- err := runner.Run(context.Background(), tk, e)
+ err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to missing credentials, got nil")
}
@@ -418,7 +477,7 @@ func TestContainerRunner_MissingSettings_FailsFast(t *testing.T) {
}
e := &storage.Execution{ID: "test-exec-2", TaskID: "test-missing-settings"}
- err := runner.Run(context.Background(), tk, e)
+ err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to missing settings, got nil")
}
@@ -504,7 +563,7 @@ func TestContainerRunner_AuthError_SyncsAndRetries(t *testing.T) {
e := &storage.Execution{ID: "auth-retry-exec", TaskID: "auth-retry-test"}
// Run — first attempt will fail with auth error, triggering sync+retry
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
// We don't check error strictly since second run may also fail (git push etc.)
// What we care about is that docker was called twice and sync was called
if callCount < 2 {
@@ -550,7 +609,7 @@ func TestContainerRunner_ClonesStoryBranch(t *testing.T) {
}
e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
os.RemoveAll(e.SandboxDir)
// Assert git checkout was called with the story branch name.
@@ -597,7 +656,7 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) {
}
e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
os.RemoveAll(e.SandboxDir)
for _, a := range cloneArgs {
@@ -663,7 +722,7 @@ func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) {
Agent: task.AgentConfig{Type: "claude"},
}
e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, noopChannel{})
os.RemoveAll(e.SandboxDir)
if cloneSrc == "https://github.com/thepeterstone/nav.git" {
@@ -752,3 +811,13 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) {
t.Fatalf("ensureStoryBranch on existing branch: %v", err)
}
}
+
+// noopChannel satisfies AgentChannel with no-op implementations for tests.
+type noopChannel struct{}
+
+func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil }
+func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil }
+func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) {
+ return "", nil
+}
+func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil }
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 09169bd..8614481 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/llm"
"github.com/thepeterstone/claudomator/internal/retry"
"github.com/thepeterstone/claudomator/internal/storage"
@@ -41,6 +42,7 @@ type Store interface {
ListTasksByStory(storyID string) ([]*task.Task, error)
UpdateStoryStatus(id string, status task.StoryState) error
CreateTask(t *task.Task) error
+ CreateEvent(e *event.Event) error
UpdateTaskCheckerReport(id, report string) error
GetCheckerTask(checkedTaskID string) (*task.Task, error)
}
@@ -52,9 +54,11 @@ type LogPather interface {
ExecLogDir(execID string) string
}
-// Runner executes a single task and returns the result.
+// Runner executes a single task and returns the result. The AgentChannel is how
+// the runner reports agent-originated signals (summary, questions, subtasks,
+// progress) back to the system.
type Runner interface {
- Run(ctx context.Context, t *task.Task, exec *storage.Execution) error
+ Run(ctx context.Context, t *task.Task, exec *storage.Execution, ch AgentChannel) error
}
// workItem is an entry in the pool's internal work queue.
@@ -89,6 +93,14 @@ type Pool struct {
dispatchDone chan struct{} // closed when the dispatch goroutine exits
Classifier *Classifier
LLM *llm.Client
+ // Budget gates paid providers against a rolling window; nil disables gating.
+ Budget BudgetGate
+}
+
+// BudgetGate reports whether a task estimated at estCost may run on a provider
+// without breaching its rolling spend window. Satisfied by *budget.Accountant.
+type BudgetGate interface {
+ Allow(provider string, estCost float64) (bool, error)
}
// Result is emitted when a task execution completes.
@@ -352,8 +364,12 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex
}
}
- err = runner.Run(ctx, t, exec)
+ sc := newStoreChannel(p.store, t.ID)
+ err = runner.Run(ctx, t, exec, sc)
exec.EndTime = time.Now().UTC()
+ if sum, ok := sc.ReportedSummary(); ok {
+ exec.Summary = sum
+ }
p.decActiveAgent(agentType, &cleaned)
p.handleRunResult(ctx, t, exec, err, agentType)
@@ -929,6 +945,44 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) {
agentType = "claude"
}
+ // Budget gating: if running on this provider could breach its rolling window
+ // (estimated by the task's max_budget_usd), reroute to the free local runner
+ // when one is registered, otherwise stop and mark the task BUDGET_EXCEEDED.
+ if p.Budget != nil {
+ if ok, berr := p.Budget.Allow(agentType, t.Agent.MaxBudgetUSD); berr != nil {
+ p.logger.Warn("budget check failed; proceeding", "error", berr, "taskID", t.ID)
+ } else if !ok {
+ if _, hasLocal := p.runners["local"]; hasLocal && agentType != "local" {
+ p.logger.Info("budget window would be breached; rerouting to local", "taskID", t.ID, "from", agentType)
+ t.Agent.Type = "local"
+ t.Agent.Model = ""
+ agentType = "local"
+ if uerr := p.store.UpdateTaskAgent(t.ID, t.Agent); uerr != nil {
+ p.logger.Error("failed to persist rerouted agent", "error", uerr, "taskID", t.ID)
+ }
+ } else {
+ now := time.Now().UTC()
+ exec := &storage.Execution{
+ ID: uuid.New().String(),
+ TaskID: t.ID,
+ StartTime: now,
+ EndTime: now,
+ Status: "BUDGET_EXCEEDED",
+ Agent: agentType,
+ ErrorMsg: fmt.Sprintf("rolling budget window cap reached for provider %q", agentType),
+ }
+ if createErr := p.store.CreateExecution(exec); createErr != nil {
+ p.logger.Error("failed to create execution record", "error", createErr)
+ }
+ if err := p.store.UpdateTaskState(t.ID, task.StateBudgetExceeded); err != nil {
+ p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateBudgetExceeded, "error", err)
+ }
+ p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: fmt.Errorf("budget cap reached for %s", agentType)}
+ return
+ }
+ }
+ }
+
// Check dependencies before taking the per-agent slot to avoid deadlock:
// if a dependent task holds the slot while waiting for its dependency to run,
// the dependency can never start (maxPerAgent=1).
@@ -1017,6 +1071,7 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) {
TaskID: t.ID,
StartTime: time.Now().UTC(),
Status: "RUNNING",
+ Agent: agentType,
}
// Pre-populate log paths so they're available in the DB immediately —
@@ -1073,8 +1128,12 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) {
}
// Run the task.
- err = runner.Run(ctx, t, exec)
+ sc := newStoreChannel(p.store, t.ID)
+ err = runner.Run(ctx, t, exec, sc)
exec.EndTime = time.Now().UTC()
+ if sum, ok := sc.ReportedSummary(); ok {
+ exec.Summary = sum
+ }
p.decActiveAgent(agentType, &cleaned)
p.handleRunResult(ctx, t, exec, err, agentType)
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index 9214872..b737e22 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -63,7 +64,7 @@ type mockRunner struct {
onRun func(*task.Task, *storage.Execution) error
}
-func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ AgentChannel) error {
m.mu.Lock()
m.calls++
cb := m.onRun
@@ -216,6 +217,63 @@ func TestPool_Submit_Subtask_GoesToCompleted(t *testing.T) {
}
}
+type fakeGate struct{ deny map[string]bool }
+
+func (g fakeGate) Allow(provider string, _ float64) (bool, error) { return !g.deny[provider], nil }
+
+func TestPool_BudgetGate_BlocksWhenNoLocalFallback(t *testing.T) {
+ store := testStore(t)
+ runner := &mockRunner{}
+ pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
+ pool.Budget = fakeGate{deny: map[string]bool{"claude": true}}
+
+ tk := makeTask("bg-1")
+ store.CreateTask(tk)
+ if err := pool.Submit(context.Background(), tk); err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+
+ result := <-pool.Results()
+ if result.Execution.Status != "BUDGET_EXCEEDED" {
+ t.Errorf("status: want BUDGET_EXCEEDED, got %q", result.Execution.Status)
+ }
+ if runner.callCount() != 0 {
+ t.Errorf("claude runner should not have run, got %d calls", runner.callCount())
+ }
+ got, _ := store.GetTask("bg-1")
+ if got.State != task.StateBudgetExceeded {
+ t.Errorf("task state: want BUDGET_EXCEEDED, got %v", got.State)
+ }
+}
+
+func TestPool_BudgetGate_ReroutesToLocal(t *testing.T) {
+ store := testStore(t)
+ claudeRunner := &mockRunner{}
+ localRunner := &mockRunner{}
+ pool := NewPool(2, map[string]Runner{"claude": claudeRunner, "local": localRunner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
+ pool.Budget = fakeGate{deny: map[string]bool{"claude": true}} // local is allowed
+
+ tk := makeTask("bg-2")
+ store.CreateTask(tk)
+ if err := pool.Submit(context.Background(), tk); err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+
+ result := <-pool.Results()
+ if result.Err != nil {
+ t.Errorf("expected success after reroute, got %v", result.Err)
+ }
+ if localRunner.callCount() != 1 {
+ t.Errorf("local runner should have run once, got %d", localRunner.callCount())
+ }
+ if claudeRunner.callCount() != 0 {
+ t.Errorf("claude runner should not have run, got %d", claudeRunner.callCount())
+ }
+ if result.Execution.Agent != "local" {
+ t.Errorf("execution agent: want local, got %q", result.Execution.Agent)
+ }
+}
+
func TestPool_Submit_Failure(t *testing.T) {
store := testStore(t)
runner := &mockRunner{err: fmt.Errorf("boom"), exitCode: 1}
@@ -391,11 +449,11 @@ func (m *logPatherMockRunner) ExecLogDir(execID string) string {
return filepath.Join(m.logDir, execID)
}
-func (m *logPatherMockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+func (m *logPatherMockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
m.mu.Lock()
m.capturedPath = e.StdoutPath
m.mu.Unlock()
- return m.mockRunner.Run(ctx, t, e)
+ return m.mockRunner.Run(ctx, t, e, ch)
}
// TestPool_Execute_LogPathsPreSetBeforeRun verifies that when the runner
@@ -1194,6 +1252,7 @@ func (m *minimalMockStore) GetStory(_ string) (*task.Story, error)
func (m *minimalMockStore) ListTasksByStory(_ string) ([]*task.Task, error) { return nil, nil }
func (m *minimalMockStore) UpdateStoryStatus(_ string, _ task.StoryState) error { return nil }
func (m *minimalMockStore) CreateTask(_ *task.Task) error { return nil }
+func (m *minimalMockStore) CreateEvent(_ *event.Event) error { return nil }
func (m *minimalMockStore) UpdateTaskCheckerReport(_ string, _ string) error { return nil }
func (m *minimalMockStore) GetCheckerTask(_ string) (*task.Task, error) { return nil, nil }
diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go
deleted file mode 100644
index 3abec05..0000000
--- a/internal/executor/gemini.go
+++ /dev/null
@@ -1,346 +0,0 @@
-package executor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
-
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-// GeminiRunner spawns the `gemini` CLI in non-interactive mode.
-type GeminiRunner struct {
- BinaryPath string // defaults to "gemini"
- Logger *slog.Logger
- LogDir string // base directory for execution logs
- APIURL string // base URL of the Claudomator API, passed to subprocesses
-}
-
-// ExecLogDir returns the log directory for the given execution ID.
-func (r *GeminiRunner) ExecLogDir(execID string) string {
- if r.LogDir == "" {
- return ""
- }
- return filepath.Join(r.LogDir, execID)
-}
-
-func (r *GeminiRunner) binaryPath() string {
- if r.BinaryPath != "" {
- return r.BinaryPath
- }
- return "gemini"
-}
-
-// Run executes the gemini CLI inside a sandboxed clone of project_dir.
-// When project_dir is set, claudomator first clones it into a temp sandbox
-// (preferring a `local` bare remote, then `origin`, then the working tree)
-// and runs the agent there. On success the sandbox is autocommitted and
-// pushed back to origin/master, then removed. On failure the sandbox is
-// preserved and its path is included in the returned error so the user can
-// inspect partial work. If the agent writes a question file before exiting,
-// Run returns *BlockedError with SandboxDir populated so a resume execution
-// can pick up in the same directory.
-func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
- projectDir := t.Agent.ProjectDir
-
- if projectDir != "" {
- if _, err := os.Stat(projectDir); err != nil {
- return fmt.Errorf("project_dir %q: %w", projectDir, err)
- }
- }
-
- logDir := r.ExecLogDir(e.ID)
- if logDir == "" {
- logDir = e.ID
- }
- if err := os.MkdirAll(logDir, 0700); err != nil {
- return fmt.Errorf("creating log dir: %w", err)
- }
-
- if e.StdoutPath == "" {
- e.StdoutPath = filepath.Join(logDir, "stdout.log")
- e.StderrPath = filepath.Join(logDir, "stderr.log")
- e.ArtifactDir = logDir
- }
-
- if e.SessionID == "" {
- if e.ResumeSessionID != "" {
- e.SessionID = e.ResumeSessionID
- } else {
- e.SessionID = e.ID
- }
- }
-
- // Sandbox setup: for new executions with a project_dir, clone into a sandbox.
- // Resume executions reuse the preserved sandbox so any partial work survives.
- // If the preserved sandbox is missing (e.g. /tmp was purged), clone fresh.
- var sandboxDir string
- var startHEAD string
- effectiveWorkingDir := projectDir
- if e.ResumeSessionID != "" {
- if e.SandboxDir != "" {
- if _, statErr := os.Stat(e.SandboxDir); statErr == nil {
- effectiveWorkingDir = e.SandboxDir
- } else {
- r.Logger.Warn("preserved sandbox missing, cloning fresh", "sandbox", e.SandboxDir, "project_dir", projectDir)
- e.SandboxDir = ""
- if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(projectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("fresh sandbox created for resume", "sandbox", sandboxDir, "project_dir", projectDir)
- }
- }
- }
- } else if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(projectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir)
- }
-
- if effectiveWorkingDir != "" {
- headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output()
- startHEAD = strings.TrimSpace(string(headOut))
- }
-
- questionFile := filepath.Join(logDir, "question.json")
- args := r.buildArgs(t, e, questionFile)
-
- if err := r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e); err != nil {
- if sandboxDir != "" {
- return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir)
- }
- return err
- }
-
- // Check whether the agent left a question before exiting.
- data, readErr := os.ReadFile(questionFile)
- if readErr == nil {
- os.Remove(questionFile)
- questionJSON := strings.TrimSpace(string(data))
- if isCompletionReport(questionJSON) {
- r.Logger.Info("treating question file as completion report", "taskID", e.TaskID)
- e.Summary = extractQuestionText(questionJSON)
- } else {
- // Preserve sandbox on BLOCKED so a resume can pick up in the same dir.
- return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir}
- }
- }
-
- // Read agent summary if written.
- summaryFile := filepath.Join(logDir, "summary.txt")
- if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil {
- os.Remove(summaryFile)
- e.Summary = strings.TrimSpace(string(summaryData))
- }
-
- // Merge sandbox back to project_dir and clean up.
- if sandboxDir != "" {
- if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil {
- return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir)
- }
- }
- return nil
-}
-
-func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error {
- cmd := exec.CommandContext(ctx, r.binaryPath(), args...)
- cmd.Env = append(os.Environ(),
- "CLAUDOMATOR_API_URL="+r.APIURL,
- "CLAUDOMATOR_TASK_ID="+e.TaskID,
- "CLAUDOMATOR_PROJECT_DIR="+projectDir,
- "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"),
- "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"),
- )
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- if workingDir != "" {
- cmd.Dir = workingDir
- }
-
- stdoutFile, err := os.Create(e.StdoutPath)
- if err != nil {
- return fmt.Errorf("creating stdout log: %w", err)
- }
- defer stdoutFile.Close()
-
- stderrFile, err := os.Create(e.StderrPath)
- if err != nil {
- return fmt.Errorf("creating stderr log: %w", err)
- }
- defer stderrFile.Close()
-
- stdoutR, stdoutW, err := os.Pipe()
- if err != nil {
- return fmt.Errorf("creating stdout pipe: %w", err)
- }
- cmd.Stdout = stdoutW
- cmd.Stderr = stderrFile
-
- if err := cmd.Start(); err != nil {
- stdoutW.Close()
- stdoutR.Close()
- return fmt.Errorf("starting gemini: %w", err)
- }
- stdoutW.Close()
-
- killDone := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
- case <-killDone:
- }
- }()
-
- var streamCost float64
- var streamErr error
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- streamCost, streamErr = parseGeminiStream(stdoutR, stdoutFile, r.Logger)
- stdoutR.Close()
- }()
-
- waitErr := cmd.Wait()
- close(killDone)
- wg.Wait()
-
- if streamCost > 0 {
- e.CostUSD = streamCost
- }
-
- if waitErr != nil {
- if exitErr, ok := waitErr.(*exec.ExitError); ok {
- e.ExitCode = exitErr.ExitCode()
- }
- if streamErr != nil {
- return streamErr
- }
- if tail := tailFile(e.StderrPath, 20); tail != "" {
- return fmt.Errorf("gemini exited with error: %w\nstderr:\n%s", waitErr, tail)
- }
- return fmt.Errorf("gemini exited with error: %w", waitErr)
- }
-
- if streamErr != nil {
- return streamErr
- }
- return nil
-}
-
-// parseGeminiStream reads streaming JSON from the gemini CLI, strips markdown
-// code fences if the output is wrapped in them, writes the inner stream-json
-// to w, and returns (costUSD, error). If a `result` event has `is_error: true`,
-// an error wrapping the result message is returned.
-func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) {
- fullOutput, err := io.ReadAll(r)
- if err != nil {
- return 0, fmt.Errorf("reading full gemini output: %w", err)
- }
- logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput))
-
- inner := stripGeminiFences(string(fullOutput), logger)
- if _, writeErr := w.Write([]byte(inner)); writeErr != nil {
- return 0, fmt.Errorf("writing gemini output: %w", writeErr)
- }
-
- // Walk lines looking for a result event so we can surface errors and cost.
- var (
- cost float64
- errMsg string
- isError bool
- )
- for _, raw := range strings.Split(inner, "\n") {
- line := strings.TrimSpace(raw)
- if line == "" {
- continue
- }
- var evt struct {
- Type string `json:"type"`
- IsError bool `json:"is_error"`
- Result string `json:"result"`
- Cost float64 `json:"total_cost_usd"`
- }
- if err := json.Unmarshal([]byte(line), &evt); err != nil {
- continue
- }
- if evt.Type == "result" {
- if evt.Cost > 0 {
- cost = evt.Cost
- }
- if evt.IsError {
- isError = true
- errMsg = evt.Result
- }
- }
- }
- if isError {
- return cost, fmt.Errorf("gemini reported error: %s", errMsg)
- }
- return cost, nil
-}
-
-// stripGeminiFences removes a surrounding ```json ... ``` markdown block if
-// present, returning the trimmed inner content. If no markdown fence is
-// found, the input is returned verbatim (no whitespace trimming) so callers
-// that expect byte-exact pass-through behavior get it.
-func stripGeminiFences(raw string, logger *slog.Logger) string {
- trimmed := strings.TrimSpace(raw)
- if start := strings.Index(trimmed, "```json"); start != -1 {
- if end := strings.LastIndex(trimmed, "```"); end > start {
- return strings.TrimSpace(trimmed[start+len("```json") : end])
- }
- logger.Warn("malformed gemini markdown block (missing closing fence); using raw output", "len", len(trimmed))
- return trimmed
- }
- return raw
-}
-
-func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string {
- // Gemini CLI uses a different command structure: gemini "instructions" [flags]
-
- instructions := t.Agent.Instructions
- if !t.Agent.SkipPlanning {
- instructions = withPlanningPreamble(instructions)
- }
-
- args := []string{
- "-p", instructions,
- "--output-format", "stream-json",
- "--yolo", // auto-approve all tools (equivalent to Claude's bypassPermissions)
- }
-
- // Note: Gemini CLI flags might differ from Claude CLI.
- // Assuming common flags for now, but these may need adjustment.
- if t.Agent.Model != "" {
- args = append(args, "--model", t.Agent.Model)
- }
-
- // Gemini CLI doesn't use --session-id for the first run in the same way,
- // or it might use it differently. For now we assume compatibility.
- if e.SessionID != "" {
- // If it's a resume, it might use different flags.
- if e.ResumeSessionID != "" {
- // This is a placeholder for Gemini's resume logic
- }
- }
-
- return args
-}
diff --git a/internal/executor/gemini_test.go b/internal/executor/gemini_test.go
deleted file mode 100644
index cd11ebc..0000000
--- a/internal/executor/gemini_test.go
+++ /dev/null
@@ -1,447 +0,0 @@
-package executor
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "log/slog"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-func TestGeminiRunner_BuildArgs_BasicTask(t *testing.T) {
- r := &GeminiRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "fix the bug",
- Model: "gemini-2.5-flash-lite",
- SkipPlanning: true,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- // Gemini CLI: instructions passed via -p for non-interactive mode
- if len(args) < 2 || args[0] != "-p" || args[1] != "fix the bug" {
- t.Errorf("expected -p <instructions> as first args, got: %v", args)
- }
-
- argMap := make(map[string]bool)
- for _, a := range args {
- argMap[a] = true
- }
- for _, want := range []string{"--output-format", "stream-json", "--model", "gemini-2.5-flash-lite"} {
- if !argMap[want] {
- t.Errorf("missing arg %q in %v", want, args)
- }
- }
-}
-
-func TestGeminiRunner_BuildArgs_PreamblePrepended(t *testing.T) {
- r := &GeminiRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "fix the bug",
- SkipPlanning: false,
- },
- }
-
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
-
- if len(args) < 2 || args[0] != "-p" {
- t.Fatalf("expected -p <instructions> as first args, got: %v", args)
- }
- if !strings.HasPrefix(args[1], planningPreamble) {
- t.Errorf("instructions should start with planning preamble")
- }
- if !strings.HasSuffix(args[1], "fix the bug") {
- t.Errorf("instructions should end with original instructions")
- }
-}
-
-func TestGeminiRunner_BuildArgs_IncludesYolo(t *testing.T) {
- r := &GeminiRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "write a doc",
- SkipPlanning: true,
- },
- }
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
- argMap := make(map[string]bool)
- for _, a := range args {
- argMap[a] = true
- }
- if !argMap["--yolo"] {
- t.Errorf("expected --yolo in gemini args (enables all tools); got: %v", args)
- }
-}
-
-func TestGeminiRunner_BuildArgs_IncludesPromptFlag(t *testing.T) {
- r := &GeminiRunner{}
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "do the thing",
- SkipPlanning: true,
- },
- }
- args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json")
- // Instructions must be passed via -p/--prompt for non-interactive headless mode,
- // not as a bare positional (which starts interactive mode).
- found := false
- for i, a := range args {
- if (a == "-p" || a == "--prompt") && i+1 < len(args) && args[i+1] == "do the thing" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("expected instructions passed via -p/--prompt flag; got: %v", args)
- }
-}
-
-func TestGeminiRunner_Run_InaccessibleProjectDir_ReturnsError(t *testing.T) {
- r := &GeminiRunner{
- BinaryPath: "true", // would succeed if it ran
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: t.TempDir(),
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- ProjectDir: "/nonexistent/path/does/not/exist",
- SkipPlanning: true,
- },
- }
- exec := &storage.Execution{ID: "test-exec"}
-
- err := r.Run(context.Background(), tk, exec)
-
- if err == nil {
- t.Fatal("expected error for inaccessible project_dir, got nil")
- }
- if !strings.Contains(err.Error(), "project_dir") {
- t.Errorf("expected 'project_dir' in error, got: %v", err)
- }
-}
-
-func TestGeminiRunner_BinaryPath_Default(t *testing.T) {
- r := &GeminiRunner{}
- if r.binaryPath() != "gemini" {
- t.Errorf("want 'gemini', got %q", r.binaryPath())
- }
-}
-
-func TestGeminiRunner_BinaryPath_Custom(t *testing.T) {
- r := &GeminiRunner{BinaryPath: "/usr/local/bin/gemini"}
- if r.binaryPath() != "/usr/local/bin/gemini" {
- t.Errorf("want custom path, got %q", r.binaryPath())
- }
-}
-
-
-func TestParseGeminiStream_ParsesStructuredOutput(t *testing.T) {
- // Simulate a stream-json input with various message types, including a result with error and cost.
- input := streamLine(`{"type":"content_block_start","content_block":{"text":"Hello,"}}`) +
- streamLine(`{"type":"content_block_delta","content_block":{"text":" World!"}}`) +
- streamLine(`{"type":"content_block_end"}`) +
- streamLine(`{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something went wrong","total_cost_usd":0.123}`)
-
- reader := strings.NewReader(input)
- var writer bytes.Buffer // To capture what's written to the output log
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-
- cost, err := parseGeminiStream(reader, &writer, logger)
-
- if err == nil {
- t.Errorf("expected an error, got nil")
- }
- if !strings.Contains(err.Error(), "something went wrong") {
- t.Errorf("expected error message to contain 'something went wrong', got: %v", err)
- }
-
- if cost != 0.123 {
- t.Errorf("expected cost 0.123, got %f", cost)
- }
-
- // Verify that the writer received the content (even if parseGeminiStream isn't fully parsing it yet)
- expectedWriterContent := input
- if writer.String() != expectedWriterContent {
- t.Errorf("writer content mismatch:\nwant:\n%s\ngot:\n%s", expectedWriterContent, writer.String())
- }
-}
-
-// TestGeminiRunner_Run_ProjectDir_RunsInSandbox verifies that when project_dir
-// is set, the gemini subprocess runs inside a sandbox clone — not in
-// project_dir itself.
-func TestGeminiRunner_Run_ProjectDir_RunsInSandbox(t *testing.T) {
- projectDir := t.TempDir()
- initGitRepo(t, projectDir)
-
- logDir := t.TempDir()
- cwdFile := filepath.Join(logDir, "gemini-cwd.txt")
-
- // Fake gemini binary that records its $PWD then exits 0.
- scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh")
- script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &GeminiRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "do work",
- ProjectDir: projectDir,
- SkipPlanning: true,
- },
- }
- e := &storage.Execution{ID: "sandbox-exec", TaskID: "task-1"}
-
- if err := r.Run(context.Background(), tk, e); err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- got, err := os.ReadFile(cwdFile)
- if err != nil {
- t.Fatalf("cwd file not written: %v", err)
- }
- cwd := string(got)
- if cwd == projectDir {
- t.Errorf("ran directly in project_dir; expected sandbox clone (cwd=%q)", cwd)
- }
- // Sandbox should be removed after successful teardown (no edits → nothing to push).
- // We can't assert the exact dir, but it should not be projectDir.
-}
-
-// TestGeminiRunner_Run_BlockedError_IncludesSandboxDir verifies that when the
-// agent writes a question file before exiting, the BlockedError carries the
-// sandbox path so resume runs in the same dir.
-func TestGeminiRunner_Run_BlockedError_IncludesSandboxDir(t *testing.T) {
- src := t.TempDir()
- initGitRepo(t, src)
- logDir := t.TempDir()
-
- scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh")
- if err := os.WriteFile(scriptPath, []byte(`#!/bin/sh
-if [ -n "$CLAUDOMATOR_QUESTION_FILE" ]; then
- printf '{"text":"Should I continue?"}' > "$CLAUDOMATOR_QUESTION_FILE"
-fi
-`), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &GeminiRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "do something",
- ProjectDir: src,
- SkipPlanning: true,
- },
- }
- e := &storage.Execution{ID: "blocked-gemini-exec", TaskID: "task-1"}
-
- err := r.Run(context.Background(), tk, e)
-
- var blocked *BlockedError
- if !errors.As(err, &blocked) {
- t.Fatalf("expected BlockedError, got: %v", err)
- }
- if blocked.SandboxDir == "" {
- t.Error("BlockedError.SandboxDir should be set when gemini task runs in a sandbox")
- }
- if _, statErr := os.Stat(blocked.SandboxDir); os.IsNotExist(statErr) {
- t.Error("sandbox directory should be preserved when blocked")
- } else {
- os.RemoveAll(blocked.SandboxDir)
- }
-}
-
-// TestGeminiRunner_Run_ExecError_PreservesSandbox verifies that when gemini
-// exits non-zero, the sandbox path is included in the wrapped error so the
-// user can inspect partial work.
-func TestGeminiRunner_Run_ExecError_PreservesSandbox(t *testing.T) {
- src := t.TempDir()
- initGitRepo(t, src)
- logDir := t.TempDir()
-
- // "false" exits 1, no output.
- r := &GeminiRunner{
- BinaryPath: "false",
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "do something",
- ProjectDir: src,
- SkipPlanning: true,
- },
- }
- e := &storage.Execution{ID: "err-gemini-exec", TaskID: "task-1"}
-
- err := r.Run(context.Background(), tk, e)
- if err == nil {
- t.Fatal("expected error from failing gemini exit")
- }
- if !strings.Contains(err.Error(), "sandbox preserved at ") {
- t.Errorf("expected error to include sandbox path; got: %v", err)
- }
- // Extract path and verify it exists.
- idx := strings.Index(err.Error(), "sandbox preserved at ")
- rest := err.Error()[idx+len("sandbox preserved at "):]
- rest = strings.TrimSuffix(rest, ")")
- rest = strings.TrimSpace(rest)
- if _, statErr := os.Stat(rest); os.IsNotExist(statErr) {
- t.Errorf("sandbox path from error should exist on disk: %q", rest)
- } else {
- os.RemoveAll(rest)
- }
-}
-
-// TestGeminiRunner_Run_ResumeUsesStoredSandboxDir verifies that a resume
-// execution runs in the preserved SandboxDir rather than cloning fresh.
-func TestGeminiRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) {
- logDir := t.TempDir()
- sandboxDir := t.TempDir()
- initGitRepo(t, sandboxDir)
- cwdFile := filepath.Join(logDir, "cwd.txt")
-
- scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh")
- script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &GeminiRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- SkipPlanning: true,
- },
- }
- e := &storage.Execution{
- ID: "resume-gemini-1",
- TaskID: "task-resume",
- ResumeSessionID: "session-abc",
- SandboxDir: sandboxDir,
- }
-
- if err := r.Run(context.Background(), tk, e); err != nil {
- t.Fatalf("Run with preserved sandbox: %v", err)
- }
-
- got, err := os.ReadFile(cwdFile)
- if err != nil {
- t.Fatalf("cwd file not written: %v", err)
- }
- if string(got) != sandboxDir {
- t.Errorf("resume should run in preserved sandbox; got cwd=%q want %q", got, sandboxDir)
- }
-}
-
-// TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh verifies that a resume
-// pointing at a missing sandbox falls back to cloning a fresh sandbox from
-// project_dir rather than failing outright.
-func TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) {
- logDir := t.TempDir()
- projectDir := t.TempDir()
- initGitRepo(t, projectDir)
-
- cwdFile := filepath.Join(logDir, "cwd.txt")
- scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh")
- script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- r := &GeminiRunner{
- BinaryPath: scriptPath,
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- ProjectDir: projectDir,
- SkipPlanning: true,
- },
- }
- staleSandbox := filepath.Join(t.TempDir(), "gone")
- e := &storage.Execution{
- ID: "resume-gemini-2",
- TaskID: "task-stale",
- ResumeSessionID: "session-xyz",
- SandboxDir: staleSandbox,
- }
-
- if err := r.Run(context.Background(), tk, e); err != nil {
- t.Fatalf("Run with stale sandbox: %v", err)
- }
-
- got, err := os.ReadFile(cwdFile)
- if err != nil {
- t.Fatalf("cwd file not written: %v", err)
- }
- cwd := string(got)
- if cwd == staleSandbox {
- t.Error("ran in stale (nonexistent) sandbox dir")
- }
- if cwd == projectDir {
- t.Error("ran directly in project_dir; expected a fresh sandbox clone")
- }
-}
-
-// TestGeminiRunner_Run_NoProjectDir_SkipsSandbox verifies that a task with no
-// project_dir doesn't trigger sandbox setup (matches LocalRunner/non-coding
-// task semantics).
-func TestGeminiRunner_Run_NoProjectDir_SkipsSandbox(t *testing.T) {
- logDir := t.TempDir()
-
- r := &GeminiRunner{
- BinaryPath: "true", // exits 0, no output
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logDir,
- }
- tk := &task.Task{
- Agent: task.AgentConfig{
- Type: "gemini",
- Instructions: "summarize: 2+2",
- SkipPlanning: true,
- // No ProjectDir
- },
- }
- e := &storage.Execution{ID: "no-pd-gemini", TaskID: "task-nopd"}
-
- if err := r.Run(context.Background(), tk, e); err != nil {
- t.Fatalf("Run without project_dir: %v", err)
- }
- if e.SandboxDir != "" {
- t.Errorf("SandboxDir should be empty for tasks without project_dir, got %q", e.SandboxDir)
- }
-}
diff --git a/internal/executor/helpers.go b/internal/executor/helpers.go
index 76bf8b1..9517492 100644
--- a/internal/executor/helpers.go
+++ b/internal/executor/helpers.go
@@ -177,29 +177,3 @@ func gitSafe(args ...string) []string {
"-c", "tag.gpgsign=false",
}, args...)
}
-
-// isCompletionReport returns true when a question-file JSON looks like a
-// completion report rather than a real user question. Heuristic: no options
-// (or empty options) and no "?" anywhere in the text.
-func isCompletionReport(questionJSON string) bool {
- var q struct {
- Text string `json:"text"`
- Options []string `json:"options"`
- }
- if err := json.Unmarshal([]byte(questionJSON), &q); err != nil {
- return false
- }
- return len(q.Options) == 0 && !strings.Contains(q.Text, "?")
-}
-
-// extractQuestionText returns the "text" field from a question-file JSON, or
-// the raw string if parsing fails.
-func extractQuestionText(questionJSON string) string {
- var q struct {
- Text string `json:"text"`
- }
- if err := json.Unmarshal([]byte(questionJSON), &q); err != nil {
- return questionJSON
- }
- return strings.TrimSpace(q.Text)
-}
diff --git a/internal/executor/local.go b/internal/executor/local.go
index 5d874c6..3f20fe7 100644
--- a/internal/executor/local.go
+++ b/internal/executor/local.go
@@ -37,10 +37,17 @@ func (r *LocalRunner) ExecLogDir(execID string) string {
return filepath.Join(r.LogDir, execID)
}
-// Run streams a chat completion to stdout.log. The response is wrapped in
-// stream-json envelopes line-by-line so downstream parsers (summary,
-// changestats) read it the same way they read Claude output.
-func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+// maxLocalToolTurns bounds the tool-use loop so a misbehaving model cannot spin
+// forever calling tools without finishing.
+const maxLocalToolTurns = 12
+
+// Run drives a chat completion against the local endpoint with the agent
+// back-channel tools (ask_user/report_summary/spawn_subtask/record_progress)
+// declared. It loops, feeding tool results back as message history, until the
+// model stops calling tools. Assistant text is written to stdout.log in the
+// same stream-json envelope Claude uses so downstream parsers keep working.
+// If the model calls ask_user, the run stops and returns a *BlockedError.
+func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
if r.Client == nil {
return fmt.Errorf("local runner: no LLM client configured")
}
@@ -70,56 +77,95 @@ func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Executio
if sys := strings.TrimSpace(t.Agent.SystemPromptAppend); sys != "" {
messages = append(messages, llm.Message{Role: "system", Content: sys})
}
- messages = append(messages, llm.Message{Role: "user", Content: t.Agent.Instructions})
+ // The agent tools are real here, so guide the model toward them with the
+ // same planning preamble the container runners use (unless skip_planning).
+ messages = append(messages, llm.Message{Role: "user", Content: buildAgentInstructions(t, true)})
temperature := t.Agent.Temperature
if temperature == nil && r.DefaultTemperature > 0 {
v := r.DefaultTemperature
temperature = &v
}
-
- req := llm.ChatRequest{
- Model: t.Agent.Model,
- Messages: messages,
- Temperature: temperature,
- MaxTokens: t.Agent.MaxTokens,
- }
+ tools := agentToolDefs()
start := time.Now()
- resp, err := r.Client.ChatStream(ctx, req, func(delta string) {
- if delta == "" {
- return
+ var totalIn, totalOut int
+ var lastModel, lastFinish string
+ blocked := false
+
+ for turn := 0; turn < maxLocalToolTurns; turn++ {
+ resp, chatErr := r.Client.Chat(ctx, llm.ChatRequest{
+ Model: t.Agent.Model,
+ Messages: messages,
+ Temperature: temperature,
+ MaxTokens: t.Agent.MaxTokens,
+ Tools: tools,
+ })
+ if chatErr != nil {
+ writeResultLine(stdout, "error", chatErr.Error(), totalIn, totalOut)
+ return fmt.Errorf("local runner: chat: %w", chatErr)
+ }
+ totalIn += resp.PromptTokens
+ totalOut += resp.OutputTokens
+ lastModel, lastFinish = resp.Model, resp.FinishReason
+
+ if resp.Content != "" {
+ writeAssistantTextLine(stdout, resp.Content)
}
- writeAssistantTextLine(stdout, delta)
- })
- if err != nil {
- writeResultLine(stdout, "error", err.Error(), 0, 0)
- return fmt.Errorf("local runner: chat: %w", err)
- }
- elapsed := time.Since(start)
- // Write one consolidated assistant envelope containing the full response.
- // extractSummary and ParseChangestatFromOutput operate per-line, so a
- // single envelope with the full text is what they expect to find.
- if resp.Content != "" {
- writeAssistantTextLine(stdout, resp.Content)
+ if len(resp.ToolCalls) == 0 {
+ break
+ }
+
+ // Re-feed: record the assistant's tool-call turn, then each tool result.
+ messages = append(messages, llm.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls})
+ for _, tc := range resp.ToolCalls {
+ result, didBlock, toolErr := dispatchAgentTool(ctx, ch, tc.Function.Name, tc.Function.Arguments)
+ if toolErr != nil {
+ writeResultLine(stdout, "error", toolErr.Error(), totalIn, totalOut)
+ return fmt.Errorf("local runner: tool %s: %w", tc.Function.Name, toolErr)
+ }
+ if didBlock {
+ blocked = true
+ break
+ }
+ messages = append(messages, llm.Message{
+ Role: "tool",
+ ToolCallID: tc.ID,
+ Name: tc.Function.Name,
+ Content: result,
+ })
+ }
+ if blocked {
+ break
+ }
}
- writeResultLine(stdout, "success", "", resp.PromptTokens, resp.OutputTokens)
+ elapsed := time.Since(start)
e.CostUSD = 0
- e.TokensIn = int64(resp.PromptTokens)
- e.TokensOut = int64(resp.OutputTokens)
+ e.TokensIn = int64(totalIn)
+ e.TokensOut = int64(totalOut)
if r.Logger != nil {
r.Logger.Info("local runner completed",
"taskID", t.ID,
- "model", resp.Model,
- "tokens_in", resp.PromptTokens,
- "tokens_out", resp.OutputTokens,
- "finish_reason", resp.FinishReason,
+ "model", lastModel,
+ "tokens_in", totalIn,
+ "tokens_out", totalOut,
+ "finish_reason", lastFinish,
+ "blocked", blocked,
"elapsed_ms", elapsed.Milliseconds(),
)
}
+
+ // If the model asked the user, stop and block. Local resume re-feeds the
+ // conversation (per-runner sovereign state) — not yet wired, so the session
+ // ID is empty and resume starts fresh for now.
+ if q, isBlocked := channelPendingQuestion(ch); isBlocked {
+ return &BlockedError{QuestionJSON: q}
+ }
+
+ writeResultLine(stdout, "success", "", totalIn, totalOut)
return nil
}
diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go
index d8ab678..2ae8380 100644
--- a/internal/executor/local_test.go
+++ b/internal/executor/local_test.go
@@ -3,7 +3,7 @@ package executor
import (
"context"
"encoding/json"
- "fmt"
+ "errors"
"io"
"log/slog"
"net/http"
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
"github.com/google/uuid"
@@ -19,60 +20,77 @@ import (
"github.com/thepeterstone/claudomator/internal/task"
)
-// fakeOpenAIServer returns an httptest.Server that replies with a streaming
-// chat completion containing the supplied content (split into chunks) plus a
-// usage record.
-func fakeOpenAIServer(t *testing.T, chunks []string, promptTok, outTok int) *httptest.Server {
+// fakeTurn is one canned chat-completion response the fake server returns.
+type fakeTurn struct {
+ content string
+ toolCalls []llm.ToolCall
+ promptTok int
+ outTok int
+}
+
+// fakeChatServer replies to /chat/completions with the supplied turns in order,
+// one per request (non-streaming JSON), so a tool-use loop can be driven
+// deterministically. Requests beyond the list repeat the final turn.
+func fakeChatServer(t *testing.T, turns []fakeTurn) *httptest.Server {
t.Helper()
- return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- flusher, _ := w.(http.Flusher)
- for _, c := range chunks {
- payload := map[string]any{
- "model": "fake",
- "choices": []map[string]any{{"delta": map[string]string{"content": c}}},
- }
- b, _ := json.Marshal(payload)
- fmt.Fprintf(w, "data: %s\n\n", b)
- if flusher != nil {
- flusher.Flush()
- }
+ var mu sync.Mutex
+ idx := 0
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ mu.Lock()
+ turn := turns[idx]
+ if idx < len(turns)-1 {
+ idx++
+ }
+ mu.Unlock()
+
+ msg := map[string]any{"role": "assistant", "content": turn.content}
+ if len(turn.toolCalls) > 0 {
+ msg["tool_calls"] = turn.toolCalls
}
- final := map[string]any{
+ resp := map[string]any{
"model": "fake",
- "choices": []map[string]any{{"delta": map[string]string{}, "finish_reason": "stop"}},
- "usage": map[string]int{"prompt_tokens": promptTok, "completion_tokens": outTok},
+ "choices": []map[string]any{{"message": msg, "finish_reason": "stop"}},
+ "usage": map[string]int{"prompt_tokens": turn.promptTok, "completion_tokens": turn.outTok},
}
- fb, _ := json.Marshal(final)
- fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", fb)
+ _ = json.NewEncoder(w).Encode(resp)
}))
}
-func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
- srv := fakeOpenAIServer(t,
- []string{"## Summary\n", "All ", "good."},
- 11, 22,
- )
- defer srv.Close()
+func toolCall(id, name, args string) llm.ToolCall {
+ return llm.ToolCall{ID: id, Type: "function", Function: llm.ToolCallFunction{Name: name, Arguments: args}}
+}
- logRoot := t.TempDir()
- r := &LocalRunner{
+func newLocalRunner(t *testing.T, srv *httptest.Server) *LocalRunner {
+ t.Helper()
+ return &LocalRunner{
Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: logRoot,
+ LogDir: t.TempDir(),
}
- tt := &task.Task{
+}
+
+func localTask() *task.Task {
+ return &task.Task{
ID: "task-1",
Name: "test",
Agent: task.AgentConfig{
Type: "local",
Model: "fake",
Instructions: "Do a thing.",
+ SkipPlanning: true, // keep the preamble out of these assertions
},
}
+}
+
+func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{{content: "## Summary\nAll good.", promptTok: 11, outTok: 22}})
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
- if err := r.Run(context.Background(), tt, exec); err != nil {
+ if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -83,45 +101,108 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
t.Errorf("tokens: want 11/22 got %d/%d", exec.TokensIn, exec.TokensOut)
}
- // Verify stdout.log contains stream-json envelopes that extractSummary can parse.
stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log")
data, err := os.ReadFile(stdoutPath)
if err != nil {
t.Fatalf("read stdout: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) < 4 {
- t.Fatalf("expected at least 4 lines (3 deltas + 1 result), got %d:\n%s", len(lines), data)
- }
- for i, line := range lines[:3] {
- var env struct {
- Type string `json:"type"`
- Message struct {
- Content []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- }
- }
- }
- if err := json.Unmarshal([]byte(line), &env); err != nil {
- t.Fatalf("line %d not JSON: %v: %s", i, err, line)
- }
- if env.Type != "assistant" {
- t.Errorf("line %d: want type=assistant, got %q", i, env.Type)
- }
+ // One assistant envelope + one result line.
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines (assistant + result), got %d:\n%s", len(lines), data)
+ }
+ var env struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal([]byte(lines[0]), &env); err != nil || env.Type != "assistant" {
+ t.Fatalf("line 0 should be an assistant envelope: %v: %s", err, lines[0])
}
- summary := extractSummary(stdoutPath)
- if !strings.Contains(summary, "All good.") {
+ if summary := extractSummary(stdoutPath); !strings.Contains(summary, "All good.") {
t.Errorf("extractSummary should find 'All good.', got %q", summary)
}
}
+func TestLocalRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) {
+ // Turn 1: model spawns a subtask. Turn 2: reports summary. Turn 3: finishes.
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "spawn_subtask", `{"name":"sub A","instructions":"do sub"}`)}, promptTok: 5, outTok: 3},
+ {toolCalls: []llm.ToolCall{toolCall("c2", "report_summary", `{"summary":"all done"}`)}, promptTok: 4, outTok: 2},
+ {content: "finished", promptTok: 2, outTok: 1},
+ })
+ 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)
+ }
+
+ if len(store.createdTasks) != 1 || store.createdTasks[0].Name != "sub A" {
+ t.Errorf("expected one spawned subtask 'sub A', got %+v", store.createdTasks)
+ }
+ if store.createdTasks[0].ParentTaskID != tt.ID {
+ t.Errorf("subtask parent: want %q, got %q", tt.ID, store.createdTasks[0].ParentTaskID)
+ }
+ if sum, ok := ch.ReportedSummary(); !ok || sum != "all done" {
+ t.Errorf("expected reported summary 'all done', got %q (set=%v)", sum, ok)
+ }
+ // Tokens accumulate across all three turns.
+ if exec.TokensIn != 11 || exec.TokensOut != 6 {
+ t.Errorf("tokens should accumulate: want 11/6, got %d/%d", exec.TokensIn, exec.TokensOut)
+ }
+}
+
+func TestLocalRunner_Run_RecordProgress(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}},
+ {content: "done"},
+ })
+ defer srv.Close()
+
+ store := &fakeChannelStore{}
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+ if err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(store, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(store.createdEvents) != 1 {
+ t.Fatalf("expected 1 progress event, got %d", len(store.createdEvents))
+ }
+ if !strings.Contains(string(store.createdEvents[0].Payload), "halfway there") {
+ t.Errorf("progress event payload: %s", store.createdEvents[0].Payload)
+ }
+}
+
+func TestLocalRunner_Run_AskUser_Blocks(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "ask_user", `{"question":"which branch?"}`)}},
+ {content: "should not be reached"},
+ })
+ defer srv.Close()
+
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+ err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID))
+
+ var be *BlockedError
+ if !errors.As(err, &be) {
+ t.Fatalf("expected *BlockedError, got %v", err)
+ }
+ if !strings.Contains(be.QuestionJSON, "which branch?") {
+ t.Errorf("BlockedError question: %q", be.QuestionJSON)
+ }
+}
+
func TestLocalRunner_Run_NoClient_Errors(t *testing.T) {
r := &LocalRunner{LogDir: t.TempDir()}
tt := &task.Task{ID: "x", Agent: task.AgentConfig{Instructions: "hi"}}
exec := &storage.Execution{ID: "exec-x"}
- err := r.Run(context.Background(), tt, exec)
+ err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID))
if err == nil || !strings.Contains(err.Error(), "no LLM client") {
t.Errorf("expected 'no LLM client' error, got %v", err)
}
@@ -134,7 +215,7 @@ func TestLocalRunner_Run_EmptyInstructions_Errors(t *testing.T) {
}
tt := &task.Task{ID: "x", Agent: task.AgentConfig{}}
exec := &storage.Execution{ID: "exec-x"}
- err := r.Run(context.Background(), tt, exec)
+ err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID))
if err == nil || !strings.Contains(err.Error(), "empty instructions") {
t.Errorf("expected empty-instructions error, got %v", err)
}
diff --git a/internal/executor/localtools.go b/internal/executor/localtools.go
new file mode 100644
index 0000000..48b862f
--- /dev/null
+++ b/internal/executor/localtools.go
@@ -0,0 +1,135 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
+)
+
+// agentToolDefs returns the four agent back-channel tools as OpenAI
+// function-calling definitions, for runners that talk to an OpenAI-compatible
+// endpoint (LocalRunner). They mirror the MCP tools the container runners expose.
+func agentToolDefs() []llm.Tool {
+ strProp := func(desc string) map[string]any {
+ return map[string]any{"type": "string", "description": desc}
+ }
+ return []llm.Tool{
+ {Type: "function", Function: llm.ToolFunction{
+ Name: "ask_user",
+ Description: "Ask the user a question when you genuinely need a decision to proceed. The task pauses until the user answers; do not call other tools after this.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "question": strProp("the question to ask, phrased as a real question"),
+ "options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional suggested answer choices"},
+ },
+ "required": []string{"question"},
+ },
+ }},
+ {Type: "function", Function: llm.ToolFunction{
+ Name: "report_summary",
+ Description: "Record a concise 2-5 sentence summary of what you accomplished. Call this before finishing.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{"summary": strProp("the summary text")},
+ "required": []string{"summary"},
+ },
+ }},
+ {Type: "function", Function: llm.ToolFunction{
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "name": strProp("short descriptive name for the subtask"),
+ "instructions": strProp("complete instructions for the subtask agent"),
+ "model": strProp("optional model override"),
+ "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
+ },
+ "required": []string{"name", "instructions"},
+ },
+ }},
+ {Type: "function", Function: llm.ToolFunction{
+ Name: "record_progress",
+ Description: "Record a short progress note that appears in the task timeline.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{"message": strProp("a short progress note")},
+ "required": []string{"message"},
+ },
+ }},
+ }
+}
+
+// dispatchAgentTool invokes one model-requested tool against the AgentChannel.
+// It returns the text to feed back to the model as the tool result, and a
+// blocked flag set when ask_user could not be answered in-session (the run must
+// stop and the task block).
+func dispatchAgentTool(ctx context.Context, ch AgentChannel, name, argsJSON string) (result string, blocked bool, err error) {
+ switch name {
+ case "ask_user":
+ var a struct {
+ Question string `json:"question"`
+ Options []string `json:"options"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ q := map[string]any{"text": a.Question}
+ if len(a.Options) > 0 {
+ q["options"] = a.Options
+ }
+ payload, _ := json.Marshal(q)
+ ans, askErr := ch.AskUser(ctx, string(payload))
+ if errors.Is(askErr, ErrAgentBlocked) {
+ return "", true, nil
+ }
+ if askErr != nil {
+ return "", false, askErr
+ }
+ return ans, false, nil
+
+ case "report_summary":
+ var a struct {
+ Summary string `json:"summary"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ if rsErr := ch.ReportSummary(ctx, a.Summary); rsErr != nil {
+ return "", false, rsErr
+ }
+ return "Summary recorded.", false, nil
+
+ case "spawn_subtask":
+ var a struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ Model string `json:"model"`
+ MaxBudgetUSD float64 `json:"max_budget_usd"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ id, ssErr := ch.SpawnSubtask(ctx, SubtaskSpec{
+ Name: a.Name,
+ Instructions: a.Instructions,
+ Model: a.Model,
+ MaxBudgetUSD: a.MaxBudgetUSD,
+ })
+ if ssErr != nil {
+ return "", false, ssErr
+ }
+ return "Created subtask " + id, false, nil
+
+ case "record_progress":
+ var a struct {
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ if rpErr := ch.RecordProgress(ctx, a.Message); rpErr != nil {
+ return "", false, rpErr
+ }
+ return "Noted.", false, nil
+
+ default:
+ return "", false, fmt.Errorf("unknown tool %q", name)
+ }
+}
diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go
index b949986..77fae3c 100644
--- a/internal/executor/preamble.go
+++ b/internal/executor/preamble.go
@@ -2,61 +2,48 @@ package executor
const planningPreamble = `## Runtime Environment
-You are running as a background agent inside Claudomator. You cannot interact
-with the user directly. However, if you need a decision or clarification:
-
-**To ask the user a question and pause:**
-1. Write a JSON object to the path in $CLAUDOMATOR_QUESTION_FILE:
- {"text": "Your question here?", "options": ["option A", "option B"]}
- (options is optional — omit it for free-text answers)
-2. Exit immediately. Do not wait. The task will be resumed with the user's answer
- as the next message in this conversation.
-
-Only use this when you genuinely need user input to proceed. The text MUST be a
-real question ending with "?". Do NOT write completion reports or status updates
-here — use $CLAUDOMATOR_SUMMARY_FILE for those. Prefer making a reasonable
-decision and noting it in your summary rather than asking.
+You are running as a background agent inside Claudomator. You cannot chat with
+the user directly, but you have these tools to coordinate:
+
+- **ask_user** — ask the user a question when you genuinely need a decision to
+ proceed. After calling it, end your turn immediately; the task pauses and is
+ resumed with the user's answer. Prefer making a reasonable decision and noting
+ it via report_summary over asking.
+- **report_summary** — record a 2-5 sentence summary of what you did. Call this
+ before you finish so the user knows the outcome.
+- **spawn_subtask** — create a child task to be run separately.
+- **record_progress** — leave a short progress note in the task timeline.
---
## Planning Step (do this first)
-Before doing any implementation work:
-
-1. Estimate: will this task take more than 3 minutes of implementation effort?
+Before doing any implementation work, estimate whether the task will take more
+than ~3 minutes of effort.
-2. If YES — break it down:
- - Create 3–7 discrete subtasks by POSTing to $CLAUDOMATOR_API_URL/api/tasks
- - Each subtask POST body should be JSON with: name, agent.instructions, agent.project_dir (copy from $CLAUDOMATOR_PROJECT_DIR), agent.model, agent.allowed_tools, and agent.skip_planning set to true
- - Set parent_task_id to $CLAUDOMATOR_TASK_ID in each POST body
- - After creating all subtasks, output a brief summary and STOP. Do not implement anything.
- - You can also specify agent.type (either "claude" or "gemini") to choose the agent for subtasks.
-
-3. If NO — proceed with the task instructions below.
+- If YES — break it into 3-7 focused pieces with spawn_subtask, then call
+ report_summary describing the breakdown and STOP. Do not implement anything.
+- If NO — proceed with the task instructions below.
---
-## Git Discipline (mandatory when project_dir is set)
+## Git Discipline (mandatory when working in a repository)
-Every change you make to the working directory **must be committed before you finish**.
-The sandbox is rejected if there are any uncommitted modifications.
+Every change you make **must be committed before you finish** — the workspace is
+rejected if there are uncommitted modifications.
-- After completing work: run "git add -A && git commit -m 'concise description'"
-- One commit is fine. Multiple focused commits are also fine.
-- If you realise the task was already done and you made no changes, that is also fine — just exit cleanly without committing.
-- Do not exit with uncommitted edits.
-- **CRITICAL:** Run ALL git commands from your current directory — do NOT use absolute paths or "cd <project_path> && git ...". Your working directory IS the project. Using absolute paths bypasses the sandbox and breaks commit tracking.
+- After completing work: run "git add -A && git commit -m 'concise description'".
+- One commit is fine; multiple focused commits are also fine.
+- If the task was already done and you made no changes, just exit cleanly.
+- Run ALL git commands from your current directory — do NOT use absolute paths
+ or "cd <path> && git ...". Your working directory IS the project.
---
-## Final Summary (mandatory)
-
-Before exiting, write a brief summary paragraph (2–5 sentences) describing what you did
-and the outcome. Write it to the path in $CLAUDOMATOR_SUMMARY_FILE:
-
- echo "Your summary here." > "$CLAUDOMATOR_SUMMARY_FILE"
+## Before Finishing
-This summary is displayed in the task UI so the user knows what happened.
+Call report_summary with a brief paragraph describing what you did and the
+outcome. This is shown in the task UI.
---
`
diff --git a/internal/executor/preamble_test.go b/internal/executor/preamble_test.go
index 5c31b4f..77a8fca 100644
--- a/internal/executor/preamble_test.go
+++ b/internal/executor/preamble_test.go
@@ -5,27 +5,37 @@ import (
"testing"
)
-func TestPlanningPreamble_ContainsFinalSummarySection(t *testing.T) {
- if !strings.Contains(planningPreamble, "## Final Summary (mandatory)") {
- t.Error("planningPreamble missing '## Final Summary (mandatory)' heading")
+func TestPlanningPreamble_ReferencesMCPTools(t *testing.T) {
+ for _, tool := range []string{"ask_user", "report_summary", "spawn_subtask", "record_progress"} {
+ if !strings.Contains(planningPreamble, tool) {
+ t.Errorf("planningPreamble should mention the %q tool", tool)
+ }
}
}
-func TestPlanningPreamble_SummaryUsesFileEnvVar(t *testing.T) {
- if !strings.Contains(planningPreamble, "CLAUDOMATOR_SUMMARY_FILE") {
- t.Error("planningPreamble should instruct agent to write summary to $CLAUDOMATOR_SUMMARY_FILE")
+func TestPlanningPreamble_NoFileEscapeHatches(t *testing.T) {
+ for _, marker := range []string{"CLAUDOMATOR_SUMMARY_FILE", "CLAUDOMATOR_QUESTION_FILE"} {
+ if strings.Contains(planningPreamble, marker) {
+ t.Errorf("planningPreamble should no longer reference %s", marker)
+ }
}
}
-func TestPlanningPreamble_SummaryInstructsEchoToFile(t *testing.T) {
- if !strings.Contains(planningPreamble, `"$CLAUDOMATOR_SUMMARY_FILE"`) {
- t.Error("planningPreamble should show example of writing to $CLAUDOMATOR_SUMMARY_FILE via echo")
+func TestPlanningPreamble_AskUserEndsTurn(t *testing.T) {
+ if !strings.Contains(planningPreamble, "end your turn") {
+ t.Error("planningPreamble should instruct the agent to end its turn after ask_user")
}
}
func TestPlanningPreamble_GitDiscipline_ForbidsAbsolutePaths(t *testing.T) {
- // Agents must not bypass the sandbox by using absolute project paths in git commands.
if !strings.Contains(planningPreamble, "do NOT use absolute paths") {
t.Error("planningPreamble should warn agents not to use absolute paths in git commands")
}
}
+
+func TestWithPlanningPreamble_PrependsToInstructions(t *testing.T) {
+ got := withPlanningPreamble("DO THE THING")
+ if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "DO THE THING") {
+ t.Error("withPlanningPreamble should prepend the preamble to the instructions")
+ }
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
index 613ebe5..e3a6102 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -34,6 +34,38 @@ type Client struct {
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
+ // ToolCalls is set on assistant messages that request tool invocations.
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ // ToolCallID links a role:"tool" result message to the call it answers.
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ // Name carries the tool name on role:"tool" result messages.
+ Name string `json:"name,omitempty"`
+}
+
+// Tool declares a function the model may call (OpenAI function-calling format).
+type Tool struct {
+ Type string `json:"type"` // always "function"
+ Function ToolFunction `json:"function"`
+}
+
+// ToolFunction describes a callable function and its JSON-schema parameters.
+type ToolFunction struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters,omitempty"`
+}
+
+// ToolCall is a function invocation the model emitted in its response.
+type ToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // "function"
+ Function ToolCallFunction `json:"function"`
+}
+
+// ToolCallFunction holds the called function's name and raw JSON arguments.
+type ToolCallFunction struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
}
// ChatRequest captures the parameters of a single Chat or ChatStream call.
@@ -45,6 +77,7 @@ type ChatRequest struct {
Temperature *float64
MaxTokens int
ResponseJSON bool
+ Tools []Tool
}
// ChatResponse is the aggregated result of a chat completion.
@@ -54,6 +87,7 @@ type ChatResponse struct {
OutputTokens int
Model string
FinishReason string
+ ToolCalls []ToolCall
}
// Chat performs a non-streaming chat completion. Rate-limit errors (HTTP 429,
@@ -87,6 +121,7 @@ func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, erro
OutputTokens: oai.Usage.CompletionTokens,
Model: oai.Model,
FinishReason: oai.Choices[0].FinishReason,
+ ToolCalls: oai.Choices[0].Message.ToolCalls,
}
return nil
})
@@ -133,6 +168,7 @@ func (c *Client) buildRequestBody(req ChatRequest, stream bool) ([]byte, error)
Model: model,
Messages: req.Messages,
Stream: stream,
+ Tools: req.Tools,
}
if req.Temperature != nil {
payload.Temperature = req.Temperature
@@ -301,6 +337,7 @@ type openAIRequest struct {
Stream bool `json:"stream,omitempty"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
ResponseFormat *responseFormat `json:"response_format,omitempty"`
+ Tools []Tool `json:"tools,omitempty"`
}
type streamOptions struct {
diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go
index 8257836..7533a8a 100644
--- a/internal/llm/client_test.go
+++ b/internal/llm/client_test.go
@@ -157,3 +157,43 @@ func TestErrFromStatus_RateLimitMarker(t *testing.T) {
t.Errorf("error should embed retry-after, got: %v", err)
}
}
+
+func TestChat_ToolsRoundTrip(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body openAIRequest
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ if len(body.Tools) != 1 || body.Tools[0].Function.Name != "do_thing" {
+ t.Errorf("tools not forwarded in request: %+v", body.Tools)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `{
+ "model": "m",
+ "choices": [{"message": {"role": "assistant", "content": "",
+ "tool_calls": [{"id": "call_1", "type": "function",
+ "function": {"name": "do_thing", "arguments": "{\"x\":1}"}}]},
+ "finish_reason": "tool_calls"}],
+ "usage": {"prompt_tokens": 3, "completion_tokens": 4}
+ }`)
+ }))
+ defer srv.Close()
+
+ c := &Client{Endpoint: srv.URL + "/v1", Model: "m"}
+ resp, err := c.Chat(context.Background(), ChatRequest{
+ Messages: []Message{{Role: "user", Content: "go"}},
+ Tools: []Tool{{Type: "function", Function: ToolFunction{
+ Name: "do_thing", Description: "d", Parameters: map[string]any{"type": "object"},
+ }}},
+ })
+ if err != nil {
+ t.Fatalf("Chat: %v", err)
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
+ }
+ tc := resp.ToolCalls[0]
+ if tc.ID != "call_1" || tc.Function.Name != "do_thing" || tc.Function.Arguments != `{"x":1}` {
+ t.Errorf("unexpected tool call parsed: %+v", tc)
+ }
+}
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 4adc1ba..e03a902 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -139,6 +140,21 @@ func (s *DB) migrate() error {
`ALTER TABLE tasks ADD COLUMN checker_report TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE executions ADD COLUMN tokens_in INTEGER`,
`ALTER TABLE executions ADD COLUMN tokens_out INTEGER`,
+ `ALTER TABLE executions ADD COLUMN agent TEXT`,
+ `CREATE TABLE IF NOT EXISTS events (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ ts DATETIME NOT NULL,
+ kind TEXT NOT NULL,
+ actor TEXT NOT NULL,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ UNIQUE(task_id, seq),
+ FOREIGN KEY (task_id) REFERENCES tasks(id)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_events_task_id_seq ON events(task_id, seq)`,
+ `CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)`,
+ `CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
@@ -175,15 +191,41 @@ func (s *DB) CreateTask(t *task.Task) error {
return fmt.Errorf("marshaling depends_on: %w", err)
}
- _, err = s.db.Exec(`
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err = tx.Exec(`
INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, story_id, acceptance_criteria, checker_for_task_id, checker_report)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority),
t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON),
t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), t.StoryID,
t.AcceptanceCriteria, t.CheckerForTaskID, t.CheckerReport,
- )
- return err
+ ); err != nil {
+ return err
+ }
+
+ // A subtask records a subtask_spawned event on its parent's stream so the
+ // parent's history shows the children it dispatched.
+ if t.ParentTaskID != "" {
+ payload, _ := json.Marshal(struct {
+ SubtaskID string `json:"subtask_id"`
+ Name string `json:"name"`
+ }{SubtaskID: t.ID, Name: t.Name})
+ if err = createEventTx(tx, &event.Event{
+ TaskID: t.ParentTaskID,
+ Kind: event.KindSubtaskSpawned,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
}
// GetTask retrieves a task by ID.
@@ -247,8 +289,17 @@ func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) {
return tasks, rows.Err()
}
-// UpdateTaskState atomically updates a task's state, enforcing valid transitions.
+// UpdateTaskState atomically updates a task's state, enforcing valid
+// transitions. The transition is attributed to the system actor; callers
+// acting on behalf of a user should use UpdateTaskStateBy.
func (s *DB) UpdateTaskState(id string, newState task.State) error {
+ return s.UpdateTaskStateBy(id, newState, event.ActorSystem)
+}
+
+// UpdateTaskStateBy is UpdateTaskState with explicit actor attribution. It
+// writes a state_change event in the same transaction as the row update so the
+// event stream and task state never diverge.
+func (s *DB) UpdateTaskStateBy(id string, newState task.State, actor event.Actor) error {
tx, err := s.db.Begin()
if err != nil {
return err
@@ -271,6 +322,14 @@ func (s *DB) UpdateTaskState(id string, newState task.State) error {
if _, err := tx.Exec(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, string(newState), now, id); err != nil {
return err
}
+ if err := createEventTx(tx, &event.Event{
+ TaskID: id,
+ Kind: event.KindStateChange,
+ Actor: actor,
+ Payload: stateChangePayload(task.State(currentState), newState),
+ }); err != nil {
+ return err
+ }
return tx.Commit()
}
@@ -305,6 +364,15 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) {
return nil, err
}
+ if err := createEventTx(tx, &event.Event{
+ TaskID: id,
+ Kind: event.KindStateChange,
+ Actor: event.ActorUser,
+ Payload: stateChangePayload(t.State, task.StateQueued),
+ }); err != nil {
+ return nil, err
+ }
+
if err := tx.Commit(); err != nil {
return nil, err
}
@@ -326,20 +394,35 @@ func (s *DB) UpdateTaskAgent(id string, agent task.AgentConfig) error {
// RejectTask sets a task's state to PENDING and stores the rejection comment.
func (s *DB) RejectTask(id, comment string) error {
- now := time.Now().UTC()
- result, err := s.db.Exec(`UPDATE tasks SET state = ?, rejection_comment = ?, updated_at = ? WHERE id = ?`,
- string(task.StatePending), comment, now, id)
+ tx, err := s.db.Begin()
if err != nil {
return err
}
- n, err := result.RowsAffected()
- if err != nil {
+ defer tx.Rollback() //nolint:errcheck
+
+ var currentState string
+ if err := tx.QueryRow(`SELECT state FROM tasks WHERE id = ?`, id).Scan(&currentState); err != nil {
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("task %q not found", id)
+ }
return err
}
- if n == 0 {
- return fmt.Errorf("task %q not found", id)
+
+ now := time.Now().UTC()
+ if _, err := tx.Exec(`UPDATE tasks SET state = ?, rejection_comment = ?, updated_at = ? WHERE id = ?`,
+ string(task.StatePending), comment, now, id); err != nil {
+ return err
}
- return nil
+
+ if err := createEventTx(tx, &event.Event{
+ TaskID: id,
+ Kind: event.KindStateChange,
+ Actor: event.ActorUser,
+ Payload: stateChangePayload(task.State(currentState), task.StatePending),
+ }); err != nil {
+ return err
+ }
+ return tx.Commit()
}
// TaskUpdate holds the fields that UpdateTask may change.
@@ -454,6 +537,7 @@ type Execution struct {
ErrorMsg string
SessionID string // claude --session-id; persisted for resume
SandboxDir string // preserved sandbox path when task is BLOCKED; resume must run here
+ Agent string // provider that ran this execution (claude/gemini/local); for budget accounting
Changestats *task.Changestats // stored as JSON; nil if not yet recorded
Commits []task.GitCommit // stored as JSON; empty if no commits
@@ -502,10 +586,10 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error {
commitsJSON = string(b)
}
if _, err := tx.Exec(`
- INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
+ INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, agent)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status,
- e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON,
+ e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON, e.Agent,
); err != nil {
return err
}
@@ -519,6 +603,32 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error {
return tx.Commit()
}
+// SpendByProviderSince returns total cost_usd per provider (executions.agent)
+// for executions started at or after `since`. Executions with no recorded agent
+// are grouped under the empty string and can be ignored by callers.
+func (s *DB) SpendByProviderSince(since time.Time) (map[string]float64, error) {
+ rows, err := s.db.Query(`
+ SELECT COALESCE(agent, ''), COALESCE(SUM(cost_usd), 0)
+ FROM executions
+ WHERE start_time >= ?
+ GROUP BY agent`, since.UTC())
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := make(map[string]float64)
+ for rows.Next() {
+ var agent string
+ var cost float64
+ if err := rows.Scan(&agent, &cost); err != nil {
+ return nil, err
+ }
+ out[agent] = cost
+ }
+ return out, rows.Err()
+}
+
// CreateExecution inserts an execution record.
func (s *DB) CreateExecution(e *Execution) error {
var changestatsJSON *string
@@ -539,23 +649,23 @@ func (s *DB) CreateExecution(e *Execution) error {
commitsJSON = string(b)
}
_, err := s.db.Exec(`
- INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status,
- e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut,
+ e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, e.Agent,
)
return err
}
// GetExecution retrieves an execution by ID.
func (s *DB) GetExecution(id string) (*Execution, error) {
- row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE id = ?`, id)
+ row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE id = ?`, id)
return scanExecution(row)
}
// ListExecutions returns executions for a task.
func (s *DB) ListExecutions(taskID string) ([]*Execution, error) {
- rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID)
+ rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID)
if err != nil {
return nil, err
}
@@ -574,7 +684,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) {
// GetLatestExecution returns the most recent execution for a task.
func (s *DB) GetLatestExecution(taskID string) (*Execution, error) {
- row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID)
+ row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID)
return scanExecution(row)
}
@@ -824,17 +934,57 @@ func (s *DB) ListRecentExecutions(since time.Time, limit int, taskID string) ([]
// UpdateTaskQuestion stores the pending question JSON on a task.
// Pass empty string to clear the question after it has been answered.
+// A non-empty question additionally emits a clarification_request event
+// (the agent asking for input); clearing emits nothing.
func (s *DB) UpdateTaskQuestion(taskID, questionJSON string) error {
- _, err := s.db.Exec(`UPDATE tasks SET question_json = ?, updated_at = ? WHERE id = ?`,
- questionJSON, time.Now().UTC(), taskID)
- return err
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err := tx.Exec(`UPDATE tasks SET question_json = ?, updated_at = ? WHERE id = ?`,
+ questionJSON, time.Now().UTC(), taskID); err != nil {
+ return err
+ }
+ if questionJSON != "" {
+ if err := createEventTx(tx, &event.Event{
+ TaskID: taskID,
+ Kind: event.KindClarificationRequest,
+ Actor: event.ActorAgent,
+ Payload: json.RawMessage(questionJSON),
+ }); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
}
-// UpdateTaskSummary stores the agent's final summary paragraph on a task.
+// UpdateTaskSummary stores the agent's final summary paragraph on a task and
+// emits a summary event.
func (s *DB) UpdateTaskSummary(taskID, summary string) error {
- _, err := s.db.Exec(`UPDATE tasks SET summary = ?, updated_at = ? WHERE id = ?`,
- summary, time.Now().UTC(), taskID)
- return err
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err := tx.Exec(`UPDATE tasks SET summary = ?, updated_at = ? WHERE id = ?`,
+ summary, time.Now().UTC(), taskID); err != nil {
+ return err
+ }
+ payload, _ := json.Marshal(struct {
+ Text string `json:"text"`
+ }{Text: summary})
+ if err := createEventTx(tx, &event.Event{
+ TaskID: taskID,
+ Kind: event.KindSummary,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }); err != nil {
+ return err
+ }
+ return tx.Commit()
}
// UpdateTaskCheckerReport sets the checker_report field on a task.
@@ -887,6 +1037,15 @@ func (s *DB) AppendTaskInteraction(taskID string, interaction task.Interaction)
string(updated), time.Now().UTC(), taskID); err != nil {
return err
}
+ payload, _ := json.Marshal(interaction)
+ if err := createEventTx(tx, &event.Event{
+ TaskID: taskID,
+ Kind: event.KindClarificationAnswer,
+ Actor: event.ActorUser,
+ Payload: payload,
+ }); err != nil {
+ return err
+ }
return tx.Commit()
}
@@ -1006,8 +1165,9 @@ func scanExecution(row scanner) (*Execution, error) {
var commitsJSON sql.NullString
var tokensIn sql.NullInt64
var tokensOut sql.NullInt64
+ var agent sql.NullString
err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status,
- &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut)
+ &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut, &agent)
if err != nil {
return nil, err
}
@@ -1015,6 +1175,7 @@ func scanExecution(row scanner) (*Execution, error) {
e.SandboxDir = sandboxDir.String
e.TokensIn = tokensIn.Int64
e.TokensOut = tokensOut.Int64
+ e.Agent = agent.String
if changestatsJSON.Valid && changestatsJSON.String != "" {
var cs task.Changestats
if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil {
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index 0e67e02..4d744a4 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -309,6 +309,44 @@ func TestListTasks_WithLimit(t *testing.T) {
}
}
+func TestSpendByProviderSince(t *testing.T) {
+ db := testDB(t)
+ now := time.Now().UTC()
+
+ tk := &task.Task{
+ ID: "spend-task", Name: "S", Agent: task.AgentConfig{Type: "claude", Instructions: "x"},
+ Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatal(err)
+ }
+
+ // Two recent claude execs, one recent gemini, one old claude (outside window).
+ execs := []*Execution{
+ {ID: "s1", TaskID: "spend-task", StartTime: now.Add(-1 * time.Hour), Status: "COMPLETED", CostUSD: 1.5, Agent: "claude"},
+ {ID: "s2", TaskID: "spend-task", StartTime: now.Add(-2 * time.Hour), Status: "COMPLETED", CostUSD: 2.0, Agent: "claude"},
+ {ID: "s3", TaskID: "spend-task", StartTime: now.Add(-30 * time.Minute), Status: "COMPLETED", CostUSD: 0.75, Agent: "gemini"},
+ {ID: "s4", TaskID: "spend-task", StartTime: now.Add(-10 * time.Hour), Status: "COMPLETED", CostUSD: 99, Agent: "claude"},
+ }
+ for _, e := range execs {
+ if err := db.CreateExecution(e); err != nil {
+ t.Fatalf("create exec %s: %v", e.ID, err)
+ }
+ }
+
+ spends, err := db.SpendByProviderSince(now.Add(-5 * time.Hour))
+ if err != nil {
+ t.Fatalf("SpendByProviderSince: %v", err)
+ }
+ if got := spends["claude"]; got != 3.5 {
+ t.Errorf("claude spend: want 3.5 (old $99 excluded), got %v", got)
+ }
+ if got := spends["gemini"]; got != 0.75 {
+ t.Errorf("gemini spend: want 0.75, got %v", got)
+ }
+}
+
func TestCreateExecution_AndGet(t *testing.T) {
db := testDB(t)
now := time.Now().UTC().Truncate(time.Second)
diff --git a/internal/storage/events.go b/internal/storage/events.go
new file mode 100644
index 0000000..2051127
--- /dev/null
+++ b/internal/storage/events.go
@@ -0,0 +1,120 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// CreateEvent appends an event to a task's stream. Seq is assigned by the
+// database as MAX(seq)+1 within a transaction. On return, e.ID, e.Seq, and
+// e.Timestamp are populated (timestamps default to time.Now().UTC()).
+func (s *DB) CreateEvent(e *event.Event) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if err := createEventTx(tx, e); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// createEventTx validates, defaults, assigns a per-task monotonic seq, and
+// inserts an event using the supplied transaction. The caller commits. This
+// lets state-changing methods write their state_change event atomically with
+// the row update.
+func createEventTx(tx *sql.Tx, e *event.Event) error {
+ if e == nil {
+ return errors.New("event is nil")
+ }
+ if e.TaskID == "" {
+ return errors.New("event.TaskID required")
+ }
+ if e.Kind == "" {
+ return errors.New("event.Kind required")
+ }
+ if e.Actor == "" {
+ return errors.New("event.Actor required")
+ }
+ if len(e.Payload) == 0 {
+ e.Payload = json.RawMessage("{}")
+ }
+ if e.ID == "" {
+ e.ID = uuid.NewString()
+ }
+ if e.Timestamp.IsZero() {
+ e.Timestamp = time.Now().UTC()
+ } else {
+ e.Timestamp = e.Timestamp.UTC()
+ }
+
+ var nextSeq int64
+ if err := tx.QueryRow(`SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE task_id = ?`, e.TaskID).Scan(&nextSeq); err != nil {
+ return fmt.Errorf("computing next seq: %w", err)
+ }
+ e.Seq = nextSeq
+
+ if _, err := tx.Exec(
+ `INSERT INTO events (id, task_id, seq, ts, kind, actor, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ e.ID, e.TaskID, e.Seq, e.Timestamp, string(e.Kind), string(e.Actor), string(e.Payload),
+ ); err != nil {
+ return fmt.Errorf("inserting event: %w", err)
+ }
+ return nil
+}
+
+// stateChangePayload builds the JSON payload for a state_change event.
+func stateChangePayload(from, to task.State) json.RawMessage {
+ b, _ := json.Marshal(struct {
+ From task.State `json:"from"`
+ To task.State `json:"to"`
+ }{From: from, To: to})
+ return b
+}
+
+// ListEvents returns events for a task in seq order. Pass sinceSeq=0 for the
+// full history, or a higher value to fetch only events strictly after that seq
+// (for incremental polling / SSE catch-up).
+func (s *DB) ListEvents(taskID string, sinceSeq int64) ([]*event.Event, error) {
+ rows, err := s.db.Query(
+ `SELECT id, task_id, seq, ts, kind, actor, payload_json FROM events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`,
+ taskID, sinceSeq,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*event.Event
+ for rows.Next() {
+ e, err := scanEvent(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
+func scanEvent(row scanner) (*event.Event, error) {
+ var (
+ e event.Event
+ kind string
+ actor string
+ payload string
+ )
+ if err := row.Scan(&e.ID, &e.TaskID, &e.Seq, &e.Timestamp, &kind, &actor, &payload); err != nil {
+ return nil, err
+ }
+ e.Kind = event.Kind(kind)
+ e.Actor = event.Actor(actor)
+ e.Payload = json.RawMessage(payload)
+ return &e, nil
+}
diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go
new file mode 100644
index 0000000..c5d3c37
--- /dev/null
+++ b/internal/storage/events_test.go
@@ -0,0 +1,456 @@
+package storage
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// makeTaskForEvent creates a minimal task row so events can satisfy the FK.
+func makeTaskForEvent(t *testing.T, db *DB, id string) {
+ t.Helper()
+ now := time.Now().UTC().Truncate(time.Second)
+ tk := &task.Task{
+ ID: id,
+ Name: id,
+ Agent: task.AgentConfig{Type: "claude", Instructions: "noop"},
+ Priority: task.PriorityNormal,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("creating task: %v", err)
+ }
+}
+
+func TestCreateEvent_AssignsSeqAndDefaults(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ e := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindStateChange,
+ Actor: event.ActorSystem,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ if e.ID == "" {
+ t.Error("expected ID populated")
+ }
+ if e.Seq != 1 {
+ t.Errorf("expected seq=1, got %d", e.Seq)
+ }
+ if e.Timestamp.IsZero() {
+ t.Error("expected Timestamp populated")
+ }
+ if string(e.Payload) != "{}" {
+ t.Errorf("expected default payload {}, got %s", e.Payload)
+ }
+}
+
+func TestCreateEvent_SeqIsMonotonicPerTask(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-A")
+ makeTaskForEvent(t, db, "task-B")
+
+ // 3 events on task-A, 2 on task-B, interleaved.
+ for _, taskID := range []string{"task-A", "task-A", "task-B", "task-A", "task-B"} {
+ e := &event.Event{
+ TaskID: taskID,
+ Kind: event.KindAgentMessage,
+ Actor: event.ActorAgent,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ }
+
+ a, err := db.ListEvents("task-A", 0)
+ if err != nil {
+ t.Fatalf("ListEvents A: %v", err)
+ }
+ if len(a) != 3 {
+ t.Fatalf("expected 3 events on task-A, got %d", len(a))
+ }
+ for i, e := range a {
+ want := int64(i + 1)
+ if e.Seq != want {
+ t.Errorf("task-A event %d: seq=%d want %d", i, e.Seq, want)
+ }
+ }
+
+ b, err := db.ListEvents("task-B", 0)
+ if err != nil {
+ t.Fatalf("ListEvents B: %v", err)
+ }
+ if len(b) != 2 {
+ t.Fatalf("expected 2 events on task-B, got %d", len(b))
+ }
+ for i, e := range b {
+ want := int64(i + 1)
+ if e.Seq != want {
+ t.Errorf("task-B event %d: seq=%d want %d", i, e.Seq, want)
+ }
+ }
+}
+
+func TestListEvents_SinceSeq(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ for i := 0; i < 5; i++ {
+ e := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindAgentMessage,
+ Actor: event.ActorAgent,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ }
+
+ got, err := db.ListEvents("task-1", 3)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("expected 2 events with seq>3, got %d", len(got))
+ }
+ if got[0].Seq != 4 || got[1].Seq != 5 {
+ t.Errorf("expected seqs 4,5; got %d,%d", got[0].Seq, got[1].Seq)
+ }
+}
+
+func TestListEvents_PreservesPayloadAndKind(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ payload := json.RawMessage(`{"question":"which env?","options":["dev","prod"]}`)
+ written := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindClarificationRequest,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }
+ if err := db.CreateEvent(written); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+
+ got, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(got))
+ }
+ e := got[0]
+ if e.Kind != event.KindClarificationRequest {
+ t.Errorf("kind: got %v want %v", e.Kind, event.KindClarificationRequest)
+ }
+ if e.Actor != event.ActorAgent {
+ t.Errorf("actor: got %v want %v", e.Actor, event.ActorAgent)
+ }
+ if string(e.Payload) != string(payload) {
+ t.Errorf("payload: got %s want %s", e.Payload, payload)
+ }
+}
+
+func TestUpdateTaskState_WritesStateChangeEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1") // starts PENDING
+
+ if err := db.UpdateTaskState("task-1", task.StateQueued); err != nil {
+ t.Fatalf("UpdateTaskState: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindStateChange {
+ t.Errorf("kind: got %v want state_change", e.Kind)
+ }
+ if e.Actor != event.ActorSystem {
+ t.Errorf("actor: got %v want system", e.Actor)
+ }
+ var payload struct {
+ From task.State `json:"from"`
+ To task.State `json:"to"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if payload.From != task.StatePending || payload.To != task.StateQueued {
+ t.Errorf("payload: got %s→%s want PENDING→QUEUED", payload.From, payload.To)
+ }
+}
+
+func TestUpdateTaskStateBy_AttributesActor(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskStateBy("task-1", task.StateCancelled, event.ActorUser); err != nil {
+ t.Fatalf("UpdateTaskStateBy: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ if events[0].Actor != event.ActorUser {
+ t.Errorf("actor: got %v want user", events[0].Actor)
+ }
+}
+
+func TestUpdateTaskState_InvalidTransition_WritesNoEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1") // PENDING
+
+ // PENDING → COMPLETED is not a valid transition.
+ if err := db.UpdateTaskState("task-1", task.StateCompleted); err == nil {
+ t.Fatal("expected invalid transition error, got nil")
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("expected no events after rejected transition, got %d", len(events))
+ }
+}
+
+func TestRejectTask_WritesUserStateChangeEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+ // Drive PENDING → QUEUED → RUNNING → READY so reject is valid.
+ for _, st := range []task.State{task.StateQueued, task.StateRunning, task.StateReady} {
+ if err := db.UpdateTaskState("task-1", st); err != nil {
+ t.Fatalf("transition to %s: %v", st, err)
+ }
+ }
+
+ if err := db.RejectTask("task-1", "needs work"); err != nil {
+ t.Fatalf("RejectTask: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ last := events[len(events)-1]
+ if last.Kind != event.KindStateChange || last.Actor != event.ActorUser {
+ t.Errorf("reject event: got kind=%v actor=%v want state_change/user", last.Kind, last.Actor)
+ }
+ var payload struct {
+ From task.State `json:"from"`
+ To task.State `json:"to"`
+ }
+ if err := json.Unmarshal(last.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.From != task.StateReady || payload.To != task.StatePending {
+ t.Errorf("payload: got %s→%s want READY→PENDING", payload.From, payload.To)
+ }
+}
+
+func TestUpdateTaskQuestion_EmitsClarificationRequest(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ q := `{"text":"which env?","options":["dev","prod"]}`
+ if err := db.UpdateTaskQuestion("task-1", q); err != nil {
+ t.Fatalf("UpdateTaskQuestion: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindClarificationRequest || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want clarification_request/agent", e.Kind, e.Actor)
+ }
+ if string(e.Payload) != q {
+ t.Errorf("payload: got %s want %s", e.Payload, q)
+ }
+}
+
+func TestUpdateTaskQuestion_ClearEmitsNothing(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskQuestion("task-1", ""); err != nil {
+ t.Fatalf("UpdateTaskQuestion clear: %v", err)
+ }
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("clearing question should emit no event, got %d", len(events))
+ }
+}
+
+func TestUpdateTaskSummary_EmitsSummaryEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskSummary("task-1", "Did the thing."); err != nil {
+ t.Fatalf("UpdateTaskSummary: %v", err)
+ }
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindSummary || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want summary/agent", e.Kind, e.Actor)
+ }
+ var payload struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.Text != "Did the thing." {
+ t.Errorf("payload text: got %q want %q", payload.Text, "Did the thing.")
+ }
+}
+
+func TestAppendTaskInteraction_EmitsClarificationAnswer(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ interaction := task.Interaction{
+ QuestionText: "which env?",
+ Options: []string{"dev", "prod"},
+ Answer: "prod",
+ }
+ if err := db.AppendTaskInteraction("task-1", interaction); err != nil {
+ t.Fatalf("AppendTaskInteraction: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindClarificationAnswer || e.Actor != event.ActorUser {
+ t.Errorf("got kind=%v actor=%v want clarification_answer/user", e.Kind, e.Actor)
+ }
+ var payload task.Interaction
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.Answer != "prod" || payload.QuestionText != "which env?" {
+ t.Errorf("payload: got %+v", payload)
+ }
+}
+
+func TestCreateTask_Subtask_EmitsSubtaskSpawnedOnParent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "parent-1")
+
+ now := time.Now().UTC().Truncate(time.Second)
+ child := &task.Task{
+ ID: "child-1",
+ Name: "Do the subthing",
+ ParentTaskID: "parent-1",
+ Agent: task.AgentConfig{Type: "claude", Instructions: "noop"},
+ Priority: task.PriorityNormal,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(child); err != nil {
+ t.Fatalf("CreateTask child: %v", err)
+ }
+
+ // Parent should have the subtask_spawned event.
+ parentEvents, err := db.ListEvents("parent-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents parent: %v", err)
+ }
+ if len(parentEvents) != 1 {
+ t.Fatalf("expected 1 event on parent, got %d", len(parentEvents))
+ }
+ e := parentEvents[0]
+ if e.Kind != event.KindSubtaskSpawned || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want subtask_spawned/agent", e.Kind, e.Actor)
+ }
+ var payload struct {
+ SubtaskID string `json:"subtask_id"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.SubtaskID != "child-1" || payload.Name != "Do the subthing" {
+ t.Errorf("payload: got %+v", payload)
+ }
+
+ // Child should have no events of its own from creation.
+ childEvents, err := db.ListEvents("child-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents child: %v", err)
+ }
+ if len(childEvents) != 0 {
+ t.Errorf("expected no events on child, got %d", len(childEvents))
+ }
+}
+
+func TestCreateTask_TopLevel_EmitsNoEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "top-1") // no parent
+
+ events, err := db.ListEvents("top-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Errorf("top-level task creation should emit no event, got %d", len(events))
+ }
+}
+
+func TestCreateEvent_RequiresFields(t *testing.T) {
+ db := testDB(t)
+
+ cases := []struct {
+ name string
+ e *event.Event
+ }{
+ {"nil", nil},
+ {"empty taskID", &event.Event{Kind: event.KindAgentMessage, Actor: event.ActorAgent}},
+ {"empty kind", &event.Event{TaskID: "t", Actor: event.ActorAgent}},
+ {"empty actor", &event.Event{TaskID: "t", Kind: event.KindAgentMessage}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := db.CreateEvent(tc.e); err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+ }
+}
diff --git a/internal/task/task.go b/internal/task/task.go
index 935a238..eeac49e 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -138,7 +138,7 @@ type BatchFile struct {
// BLOCKED may advance to READY when all subtasks complete, or back to QUEUED on user answer.
var validTransitions = map[State]map[State]bool{
StatePending: {StateQueued: true, StateCancelled: true},
- StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true}, // READY: parent task completed via subtask delegation
+ StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true, StateBudgetExceeded: true}, // READY: subtask delegation; BUDGET_EXCEEDED: dispatcher budget gate blocks before running
StateRunning: {StateReady: true, StateCompleted: true, StateFailed: true, StateTimedOut: true, StateCancelled: true, StateBudgetExceeded: true, StateBlocked: true},
StateReady: {StateCompleted: true, StatePending: true},
StateFailed: {StateQueued: true}, // retry