From c24fdaa9b6bce49f5c0a1c589fd064d46b62b88d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 08:53:53 +0000 Subject: docs: add OSS replacement redesign plan Captures the locked architectural decisions from the chatbot-driven redesign discussion: SQLite as source of truth with external trackers as input feeds, events table replacing ad-hoc task columns, per-runner structured agent channel, three-runner roster with classifier, scoped auth, multi-dimensional budget, per-runner sovereign resume. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- docs/plans/claudomator-oss-replacement.md | 199 ++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/plans/claudomator-oss-replacement.md diff --git a/docs/plans/claudomator-oss-replacement.md b/docs/plans/claudomator-oss-replacement.md new file mode 100644 index 0000000..f0fd3dc --- /dev/null +++ b/docs/plans/claudomator-oss-replacement.md @@ -0,0 +1,199 @@ +# Claudomator OSS Replacement — Architectural Redesign + +## Context + +Claudomator today is a useful but overgrown harness: ~10 task states, a stories layer, an elaborate-via-REST endpoint, file-based agent escape hatches, a manual task-creation web form, and a stub GeminiRunner that silently returns canned output. The primary mode of interaction we actually want is now: **a chatbot drives task creation and orchestration on the user's behalf; the human only intervenes when an agent asks a question; the web UI is for observability, not for primary input.** + +This plan locks the architectural shape of that redesign. Implementation phasing is sketched at the end but the focus here is decision capture so subsequent work can proceed without re-litigating. + +**Mission:** +- Reduce custom code we maintain. +- Support chatbot-as-primary-driver (us, or any chatbot with MCP). +- Push-notify the user when human input is needed. +- Web UI = observability layer (task tree, live logs, event stream, accept/reject). +- Multi-runner (Claude cloud, Gemini cloud, OSS local) with budget-aware routing. + +**Non-goals (deferred):** +- Cross-model resume — each runner is sovereign over its own continuity. +- Non-code-task UX optimization — shape must remain *possible* (project_dir is optional) but no code or docs accommodate it yet. +- Autonomous chatbot responding to agent questions without human — the subscription model must not foreclose it, but it's not built. + +--- + +## Locked Decisions + +### 1. SQLite is source of truth; external trackers are input feeds + +Tasks live in our `tasks` table. GitHub/GitLab/whatever-tracker integrations create and update tasks through the existing webhook surface — they are not the data model. Each task carries an `external_refs` slice (`[{tracker, url}]`) for opportunistic back-syncing of state, but nothing load-bearing depends on the external tracker being available. + +Rejected: GitHub Issues as source of truth (Option B). Too much lock-in, non-code tasks fit poorly, and we'd still need our own DB for execution data. + +### 2. Events table replaces ad-hoc task-state columns + +A single `events` table replaces `question_json`, `interactions_json`, `summary`, `elaboration_input` on `tasks`. Schema: + +``` +events: id, task_id, seq, ts, kind, actor, payload_json +``` + +- `seq` is monotonic per task. +- `actor ∈ {agent, user, chatbot, system, webhook}`. +- `kind ∈ {state_change, agent_message, clarification_request, clarification_answer, human_message, summary, commit_made, subtask_spawned, cost_report, execution_started, execution_ended}`. +- Granularity: **milestones, not raw stream chunks.** Raw Claude stream-json stays in `stdout.log` on disk, linked from the `execution_started` event. +- Replay: read events in `seq` order, fold state transitions, reconstruct UI. **For humans, not for re-prompting the agent.** Cross-model agent replay is explicitly out of scope. + +### 3. Per-runner structured agent channel (no more file conventions) + +Today the agent uses `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE`, and `POST /api/tasks` for subtasks — orchestrated by a ~1KB prompt preamble. We replace this with a `Runner` interface that exposes normalized callbacks: + +```go +type AgentChannel interface { + OnAskUser(question string, schema any) (answer string, err error) + OnReportSummary(text string) + OnSpawnSubtask(spec TaskSpec) (taskID string, err error) + OnRecordProgress(message string) +} +``` + +Each runner picks its own wire: +- **ClaudeRunner:** registers an in-process MCP server via `--mcp-config`. Tools (`ask_user`, `report_summary`, `spawn_subtask`, `record_progress`) bound to a per-task scoped token minted at subprocess spawn. Server derives `task_id` from token; never trusts agent-supplied IDs. +- **GeminiRunner:** uses Gemini CLI tool-use registration (or direct API if CLI tool-use proves unworkable — to determine during build). +- **LocalRunner:** uses Ollama-style tool-use API. Falls back to file conventions only if a future runner lacks tool-use plumbing. + +**Safety tradeoffs accepted:** +- Atomicity at run boundary: MCP calls land mid-run, file-based intent lands at exit. Mitigated by designing tools idempotent (`report_summary` upserts, `ask_user` creates a clarification event which is fine mid-run by design). +- Blast radius: per-task token scopes the agent's access to one task's data. +- Portability: per-runner transport means non-MCP runners stay supported via callback abstraction. + +### 4. Web UI = observability + +**In:** task tree view, live log tailing, accept/reject buttons, event-stream timeline per task, budget headroom display. + +**Out:** manual task creation form, elaborate textbox, stories dashboard. + +Chatbots create tasks via the MCP server. Humans visit the web UI to watch, intervene on `READY`, or answer a `BLOCKED` clarification when push notification pings them. + +### 5. Three runners + Classifier + +| Runner | Status | Transport | Resume mechanism | +|---|---|---|---| +| ClaudeRunner | exists, works | `claude -p` subprocess + MCP | provider-side via `--resume ` | +| GeminiRunner | scratch rewrite (current stub deleted) | `gemini` CLI subprocess + tool-use registration | message-history re-feed | +| LocalRunner | exists (Phase 1 of `local-oss-runner.md`), extend with agent-channel | OpenAI-compat HTTP + tool-use | message-history re-feed | + +Classifier stays. Its job becomes: given a task, choose Claude-cloud vs Gemini-cloud vs Local, and within Claude/Gemini, the specific model. **Budget state feeds this decision** (#7). + +### 6. Auth: loopback default + reverse proxy + scoped tokens + +- Server binds to `127.0.0.1` by default. Refuses to bind non-loopback unless `external_bind_allowed = true` is set in config — fail loud on misconfiguration. +- External network access: user fronts with Tailscale / Cloudflare Access / Caddy. Out of our code, battle-tested. +- Internal tokens, each scoped: + - **GitHub webhooks:** HMAC-SHA256 (already exists). + - **Chatbot MCP + web UI:** one shared bearer token from config; user pastes into MCP client config. One user, one credential. + - **Agent MCP back-channel:** per-task token minted at subprocess spawn; bound to single task ID; expires at task termination. Server resolves `task_id` from token — never accepts agent-supplied `task_id`. + - **Web push:** standard VAPID subscriptions. + +### 7. Budget — multi-dimensional + +- Per-task `max_budget_usd` (exists). +- **Rolling 5-hour budget per provider** (Claude, Gemini) — track spend in a window. Dispatcher refuses to start a paid task that would breach the window. Classifier prefers local when headroom is thin. +- Optional per-day / per-week hard ceiling in config. +- Local runners bypass entirely — free. +- UI surfaces headroom: e.g. "Claude: 67% remaining in current 5h window." + +This is genuinely new code: budget accountant, classifier hook, UI surface. Not throwaway. + +### 8. Resume = per-runner sovereign opaque state + +Each runner persists its own resume state as opaque JSON in `executions.runner_state_json`. The Runner interface: + +```go +Resume(ctx context.Context, opaqueState []byte) (*Result, error) +``` + +- **ClaudeRunner** stores `{session_id}`, calls `--resume`. **Prompt caching survives** — this is the killer feature that justifies the special case. Cost can differ 10x on long resumed tasks. +- **GeminiRunner** stores `{messages: [...]}`, re-feeds on resume. +- **LocalRunner** stores `{messages: [...]}`, re-feeds on resume. + +The "specialness" of Claude is entirely internal to ClaudeRunner — the dispatcher and event store don't know. Cross-model resume is **not** a goal; if we ever want it, revisit then. + +### 9. Subscription model: push, agent-return-friendly + +Server broadcasts events on WebSocket (live UI) + web push (high-signal subset: `clarification_request`, terminal state changes, `summary`). Subscription record: + +``` +subscriptions: id, endpoint, kind_filter, task_filter, actor_label +``` + +Today's only subscribers are browser WebSocket clients and web-push endpoints. The model is flexible enough that adding an autonomous-chatbot subscriber later is one row: `{endpoint: chatbot_url, kinds: ["clarification_request"], actor_label: "auto-responder"}`. The `POST /api/tasks/{id}/answer` endpoint already accepts any caller; the `actor` field on the resulting `clarification_answer` event records who answered. + +--- + +## What Gets Deleted + +| Surface | Reason | +|---|---| +| `stories` table + handlers + UI dashboard | Folded into events + task tree view | +| `tasks.question_json`, `interactions_json`, `summary`, `elaboration_input` | Replaced by events table | +| `POST /api/tasks/elaborate` endpoint | Chatbots elaborate in-conversation; no REST shell-out path | +| `sanitizeElaboratedTask` keyword heuristics | Same | +| Web UI: create-task form, elaborate textbox, stories dashboard | Out of scope for observability UI | +| `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE` env conventions | Replaced by per-runner agent channel | +| GeminiRunner stub (`internal/executor/gemini.go` simulated output) | Scratch rewrite as real runner | +| Planning preamble's file-protocol section | Shrunk to "use your tools" — runners register tools per-transport | + +## What Gets Built + +| Surface | Notes | +|---|---| +| `internal/event` package | `Event` struct, kind constants, `EventStore` interface | +| `events` table + scan/CRUD methods | Additive migration; indexed on `(task_id, seq)` | +| Per-task scoped MCP server | Hosted inside claudomator process; tokens minted per subprocess spawn | +| Chatbot-facing MCP server | Tools: `submit_task`, `list_tasks`, `get_task`, `get_events`, `answer_question`, `accept_task`, `reject_task`, `cancel_task` | +| `AgentChannel` interface + per-runner implementations | Normalized callbacks; transport hidden in runner | +| Real GeminiRunner | Subprocess + stream parser + sandbox integration; mirrors ClaudeRunner shape | +| Budget accountant | Rolling 5h window per provider; classifier hook; UI surface | +| Loopback-default binding + `external_bind_allowed` flag | Hard refusal on misconfig | +| Per-task token minting + scoping | Auth context for agent MCP calls | +| Web UI event-stream timeline component | Replaces ad-hoc question/summary panels | +| `external_refs` field on tasks | For opportunistic tracker back-sync | + +--- + +## Phasing + +These are sequencing notes, not commitments to PR boundaries. Each phase should be independently shippable. + +**Phase 1 — Events table + Runner interface refinement.** Add `events` table, `EventStore`, write paths for state changes and existing question/summary flows. Refactor `Runner` to expose `AgentChannel` callbacks; ClaudeRunner keeps file-based wire as transitional default. No user-visible behavior change yet. + +**Phase 2 — ClaudeRunner MCP agent channel.** Per-task MCP server, token minting, four agent tools. Delete file-based escape hatches. Shrink preamble. Migration: existing in-flight tasks finish on the old wire; new tasks use MCP. + +**Phase 3 — Chatbot MCP server + UI rebuild.** Chatbot-facing MCP server. Web UI gets event-stream timeline, drops create form / elaborate / stories. Elaborate REST endpoint removed. + +**Phase 4 — GeminiRunner scratch rewrite.** Real subprocess + stream parser + sandbox + tool-use registration. Plug into AgentChannel. + +**Phase 5 — LocalRunner agent channel.** Extend the existing LocalRunner (from `local-oss-runner.md`) to participate in AgentChannel via Ollama tool-use. Was previously fire-and-forget; now first-class for orchestration tasks. + +**Phase 6 — Budget accountant.** Rolling 5h window tracking, classifier integration, UI headroom display, dispatcher gating. + +**Phase 7 — Auth tightening.** Loopback default, `external_bind_allowed` flag, refuse-bind enforcement, chatbot bearer rotation flow. + +**Phase 8 — Cleanup pass.** Delete `stories`, deprecated columns, dead handlers, dead UI. Update CLAUDE.md and ADRs. + +--- + +## Open Design Questions (resolve at phase entry) + +- **MCP tool schemas.** Exact arg shapes for `ask_user` (free text? JSON schema for typed answers?), `spawn_subtask` (how much of `TaskSpec` is exposed?), `submit_task` (how to handle `project_dir` resolution from chatbot side?). Defer to Phase 1/3 detailed design. +- **Gemini transport for tool-use.** Whether `gemini` CLI's tool-use is reliable enough or we need to drop to the SDK. Decide in Phase 4 spike. +- **Web push subset.** Final list of event kinds that trigger push vs WebSocket-only. Default: `clarification_request`, `state_change → READY/FAILED/BLOCKED`, `summary`. Tune in Phase 3. +- **Single shared bearer token vs per-client.** If multi-device gets painful, revisit. Defer. +- **Budget hard-ceiling defaults.** What's the user's actual daily Claude spend tolerance? Surface in config with no default; require explicit value. + +## Deferred (revisit only if pressure appears) + +- Cross-model resume. +- Non-code-task UX (event types for "agent produced text", task-with-conversation-as-primary view). Shape must remain possible; no code or docs accommodate it. +- Autonomous chatbot answering clarifications. Subscription model supports it; no implementation. +- GitLab webhook handler (parallel to GitHub). Add when needed. +- Todoist or similar personal-task-tracker bridges. Same shape as GitHub webhook; add when needed. -- cgit v1.2.3 From 39cc108b5f0bf3d0238a1707d657a77b7a3df184 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 09:08:52 +0000 Subject: feat(event,storage): add events table substrate for OSS redesign New internal/event package defines the Event type with canonical Kind and Actor constants. New events table is appended via additive migration (id, task_id, seq, ts, kind, actor, payload_json) with seq as per-task monotonic, assigned by the storage layer inside a transaction. CreateEvent + ListEvents support the future migration of question_json, interactions_json, summary, and elaboration_input off the tasks table into a unified, replayable event stream. No existing code paths are modified yet; this is pure substrate for Phase 1. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/event/event.go | 50 +++++++++++ internal/event/event_test.go | 42 ++++++++++ internal/storage/db.go | 14 ++++ internal/storage/events.go | 99 ++++++++++++++++++++++ internal/storage/events_test.go | 182 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 internal/event/event.go create mode 100644 internal/event/event_test.go create mode 100644 internal/storage/events.go create mode 100644 internal/storage/events_test.go 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/storage/db.go b/internal/storage/db.go index 4adc1ba..3a4f059 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -139,6 +139,20 @@ 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`, + `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 { diff --git a/internal/storage/events.go b/internal/storage/events.go new file mode 100644 index 0000000..82016ab --- /dev/null +++ b/internal/storage/events.go @@ -0,0 +1,99 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/event" +) + +// 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 { + 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() + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + 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 tx.Commit() +} + +// 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..3b342c7 --- /dev/null +++ b/internal/storage/events_test.go @@ -0,0 +1,182 @@ +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 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") + } + }) + } +} -- cgit v1.2.3 From dc97d7ae00bd25668ba495fba249ceade0c6b100 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 08:07:50 +0000 Subject: feat(storage): emit state_change events on task transitions UpdateTaskState now writes a state_change event in the same transaction as the row update, so the event stream and task state never diverge. Adds UpdateTaskStateBy(id, newState, actor) for explicit actor attribution; UpdateTaskState delegates with ActorSystem (correct for the executor, which drives the state machine). RejectTask and ResetTaskForRetry also emit state_change events attributed to the user. API accept and cancel handlers now attribute their transitions to the user via UpdateTaskStateBy. The executor's Store interface is unchanged, so no test doubles needed updating. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/server.go | 5 +- internal/storage/db.go | 60 +++++++++++++++++++---- internal/storage/events.go | 35 ++++++++++--- internal/storage/events_test.go | 106 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 18 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 28cfe4a..ff3a111 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,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" @@ -285,7 +286,7 @@ func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) { 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 { + if err := s.store.UpdateTaskStateBy(taskID, task.StateCancelled, event.ActorUser); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"}) return } @@ -703,7 +704,7 @@ func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) { }) return } - if err := s.store.UpdateTaskState(id, task.StateCompleted); err != nil { + if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, event.ActorUser); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } diff --git a/internal/storage/db.go b/internal/storage/db.go index 3a4f059..42d7800 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" ) @@ -261,8 +262,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 @@ -285,6 +295,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() } @@ -319,6 +337,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 } @@ -340,20 +367,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(¤tState); 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. diff --git a/internal/storage/events.go b/internal/storage/events.go index 82016ab..2051127 100644 --- a/internal/storage/events.go +++ b/internal/storage/events.go @@ -1,6 +1,7 @@ package storage import ( + "database/sql" "encoding/json" "errors" "fmt" @@ -8,12 +9,29 @@ import ( "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") } @@ -38,12 +56,6 @@ func (s *DB) CreateEvent(e *event.Event) error { e.Timestamp = e.Timestamp.UTC() } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - 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) @@ -56,7 +68,16 @@ func (s *DB) CreateEvent(e *event.Event) error { ); err != nil { return fmt.Errorf("inserting event: %w", err) } - return tx.Commit() + 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 diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go index 3b342c7..1f03aeb 100644 --- a/internal/storage/events_test.go +++ b/internal/storage/events_test.go @@ -160,6 +160,112 @@ func TestListEvents_PreservesPayloadAndKind(t *testing.T) { } } +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 TestCreateEvent_RequiresFields(t *testing.T) { db := testDB(t) -- cgit v1.2.3 From b72978f4454235a00a2e5c92b5332b985cc08fa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 08:10:03 +0000 Subject: feat(storage): dual-write question/summary/interaction flows to events The legacy task columns (question_json, summary, interactions_json) now also emit events in the same transaction as the column update: - UpdateTaskQuestion(non-empty) -> clarification_request (agent); the empty-string clear-on-answer emits nothing - UpdateTaskSummary -> summary (agent) - AppendTaskInteraction -> clarification_answer (user) This populates the event stream from the existing Q&A and summary paths without removing the old columns yet, setting up their eventual deletion in the cleanup phase. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/storage/db.go | 63 +++++++++++++++++++++--- internal/storage/events_test.go | 103 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/internal/storage/db.go b/internal/storage/db.go index 42d7800..a871c71 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -880,17 +880,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. @@ -943,6 +983,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() } diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go index 1f03aeb..3ce79ab 100644 --- a/internal/storage/events_test.go +++ b/internal/storage/events_test.go @@ -266,6 +266,109 @@ func TestRejectTask_WritesUserStateChangeEvent(t *testing.T) { } } +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 TestCreateEvent_RequiresFields(t *testing.T) { db := testDB(t) -- cgit v1.2.3 From 48a8e49cdda833f8af22e839ca3c102c40c97e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:19:23 +0000 Subject: feat(storage): emit subtask_spawned events on the parent stream CreateTask is now transactional and, when a task has a parent_task_id, records a subtask_spawned event (actor agent) on the parent's stream so the parent's history shows the children it dispatched. Top-level task creation (no parent) emits nothing. This closes the one agent-originated signal that flows during a run today (subtasks POSTed to the API per the planning preamble) but previously left no trace in the event stream. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/storage/db.go | 32 ++++++++++++++++++-- internal/storage/events_test.go | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/internal/storage/db.go b/internal/storage/db.go index a871c71..adb7c02 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -190,15 +190,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. diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go index 3ce79ab..c5d3c37 100644 --- a/internal/storage/events_test.go +++ b/internal/storage/events_test.go @@ -369,6 +369,71 @@ func TestAppendTaskInteraction_EmitsClarificationAnswer(t *testing.T) { } } +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) -- cgit v1.2.3 From c7d95f3992d24f86ff71e5f3e18260a8ef8a09f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:47:07 +0000 Subject: feat(executor): introduce AgentChannel seam for runner signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines AgentChannel — the normalized interface by which a runner reports agent-originated signals (AskUser, ReportSummary, SpawnSubtask, RecordProgress) — plus a default storeChannel implementation backed by storage. Runner.Run now takes an AgentChannel; the pool constructs one per execution. The file transport routes its post-exit summary detection through ch.ReportSummary (buffered onto the execution so the pool still applies its extract/synthesize fallbacks, no double-write). AskUser returns ErrAgentBlocked since write-and-exit cannot answer in-session; question persistence stays with the pool's BlockedError handling. SpawnSubtask and RecordProgress are implemented and tested, ready for the MCP transport in Phase 2 where the channel becomes fully load-bearing. Store gains CreateEvent so the channel can emit agent_message events. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/server_test.go | 2 +- internal/executor/channel.go | 117 +++++++++++++++++++++++++++++++++++ internal/executor/channel_test.go | 118 ++++++++++++++++++++++++++++++++++++ internal/executor/claude.go | 6 +- internal/executor/claude_test.go | 8 +-- internal/executor/container.go | 12 ++-- internal/executor/container_test.go | 12 ++-- internal/executor/executor.go | 12 ++-- internal/executor/executor_test.go | 8 ++- internal/executor/gemini.go | 6 +- internal/executor/gemini_test.go | 14 ++--- internal/executor/local.go | 2 +- internal/executor/local_test.go | 6 +- 13 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 internal/executor/channel.go create mode 100644 internal/executor/channel_test.go diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 2530d55..f902495 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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() } diff --git a/internal/executor/channel.go b/internal/executor/channel.go new file mode 100644 index 0000000..76df94f --- /dev/null +++ b/internal/executor/channel.go @@ -0,0 +1,117 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "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 +} + +// storeChannel is the default AgentChannel backed by storage. Summary reports +// are buffered onto the execution record so the pool persists them once (with +// its extract/synthesize fallbacks); other signals are written immediately. +type storeChannel struct { + store channelStore + taskID string + exec *storage.Execution +} + +var _ AgentChannel = (*storeChannel)(nil) + +func newStoreChannel(store channelStore, taskID string, exec *storage.Execution) *storeChannel { + return &storeChannel{store: store, taskID: taskID, exec: exec} +} + +// AskUser on the default (file-transport) channel cannot deliver an answer +// in-session, so it always reports the run as blocked. Question persistence is +// owned by the pool's BlockedError handling. +func (c *storeChannel) AskUser(_ context.Context, _ string) (string, error) { + return "", ErrAgentBlocked +} + +// ReportSummary buffers the summary onto the execution record. The pool persists +// it (preferring this value over its extract/synthesize fallbacks). +func (c *storeChannel) ReportSummary(_ context.Context, summary string) error { + if c.exec != nil { + c.exec.Summary = summary + } + return nil +} + +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..822dd35 --- /dev/null +++ b/internal/executor/channel_test.go @@ -0,0 +1,118 @@ +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "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_ReturnsBlocked(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1", &storage.Execution{}) + 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) + } +} + +func TestStoreChannel_ReportSummary_BuffersOntoExec(t *testing.T) { + e := &storage.Execution{} + ch := newStoreChannel(&fakeChannelStore{}, "task-1", e) + if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { + t.Fatalf("ReportSummary: %v", err) + } + if e.Summary != "did the thing" { + t.Errorf("expected exec.Summary set, got %q", e.Summary) + } +} + +func TestStoreChannel_ReportSummary_NilExec(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1", nil) + if err := ch.ReportSummary(context.Background(), "x"); err != nil { + t.Errorf("expected nil err with nil exec, got %v", err) + } +} + +func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-1", &storage.Execution{}) + 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", &storage.Execution{}) + 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", &storage.Execution{}) + 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 index 3c87f26..0123754 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -51,7 +51,7 @@ func (r *ClaudeRunner) binaryPath() string { // 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 { +func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { projectDir := t.Agent.ProjectDir // Validate project_dir exists when set. @@ -164,7 +164,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi // 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) + _ = ch.ReportSummary(ctx, 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. @@ -177,7 +177,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi summaryFile := filepath.Join(logDir, "summary.txt") if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { os.Remove(summaryFile) // consumed - e.Summary = strings.TrimSpace(string(summaryData)) + _ = ch.ReportSummary(ctx, strings.TrimSpace(string(summaryData))) } // Merge sandbox back to project_dir and clean up. diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index c01e160..0e76260 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -259,7 +259,7 @@ func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) { } // Run completes successfully (binary is "true"). - _ = r.Run(context.Background(), tk, exec) + _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, 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 @@ -284,7 +284,7 @@ func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { } exec := &storage.Execution{ID: "test-exec"} - err := r.Run(context.Background(), tk, exec) + err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) if err == nil { t.Fatal("expected error for inaccessible working_dir, got nil") @@ -732,7 +732,7 @@ func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { SandboxDir: sandboxDir, } - _ = r.Run(context.Background(), tk, exec) + _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) got, err := os.ReadFile(cwdFile) if err != nil { @@ -778,7 +778,7 @@ func TestClaudeRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { SandboxDir: staleSandbox, } - if err := r.Run(context.Background(), tk, e); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { t.Fatalf("Run with stale sandbox: %v", err) } diff --git a/internal/executor/container.go b/internal/executor/container.go index 61ac29c..4269ef1 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -100,7 +100,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 == "" { @@ -227,7 +227,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() @@ -241,7 +241,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 { @@ -257,7 +257,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 @@ -372,7 +372,7 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto questionJSON := strings.TrimSpace(string(data)) if isCompletionReport(questionJSON) { r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) + _ = ch.ReportSummary(ctx, extractQuestionText(questionJSON)) } else { if e.SessionID == "" { r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) @@ -389,7 +389,7 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto summaryFile := filepath.Join(logDir, "summary.txt") if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { os.Remove(summaryFile) // consumed - e.Summary = strings.TrimSpace(string(summaryData)) + _ = ch.ReportSummary(ctx, strings.TrimSpace(string(summaryData))) } // 5. Post-execution: push changes if successful diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index f0b2a3a..5ee3a3c 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -139,7 +139,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, exec)) if err == nil { t.Fatal("expected error due to mocked docker failure") } @@ -378,7 +378,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, e)) if err == nil { t.Fatal("expected error due to missing credentials, got nil") } @@ -418,7 +418,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, e)) if err == nil { t.Fatal("expected error due to missing settings, got nil") } @@ -504,7 +504,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, e)) // 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 +550,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, e)) os.RemoveAll(e.SandboxDir) // Assert git checkout was called with the story branch name. @@ -597,7 +597,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, e)) os.RemoveAll(e.SandboxDir) for _, a := range cloneArgs { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 09169bd..76e67b8 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. @@ -352,7 +356,7 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex } } - err = runner.Run(ctx, t, exec) + err = runner.Run(ctx, t, exec, newStoreChannel(p.store, t.ID, exec)) exec.EndTime = time.Now().UTC() p.decActiveAgent(agentType, &cleaned) @@ -1073,7 +1077,7 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { } // Run the task. - err = runner.Run(ctx, t, exec) + err = runner.Run(ctx, t, exec, newStoreChannel(p.store, t.ID, exec)) exec.EndTime = time.Now().UTC() p.decActiveAgent(agentType, &cleaned) diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 9214872..d98efbf 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 @@ -391,11 +392,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 +1195,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 index 3abec05..d229361 100644 --- a/internal/executor/gemini.go +++ b/internal/executor/gemini.go @@ -49,7 +49,7 @@ func (r *GeminiRunner) binaryPath() string { // 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 { +func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { projectDir := t.Agent.ProjectDir if projectDir != "" { @@ -136,7 +136,7 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi questionJSON := strings.TrimSpace(string(data)) if isCompletionReport(questionJSON) { r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) + _ = ch.ReportSummary(ctx, 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} @@ -147,7 +147,7 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi summaryFile := filepath.Join(logDir, "summary.txt") if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { os.Remove(summaryFile) - e.Summary = strings.TrimSpace(string(summaryData)) + _ = ch.ReportSummary(ctx, strings.TrimSpace(string(summaryData))) } // Merge sandbox back to project_dir and clean up. diff --git a/internal/executor/gemini_test.go b/internal/executor/gemini_test.go index cd11ebc..c8f7422 100644 --- a/internal/executor/gemini_test.go +++ b/internal/executor/gemini_test.go @@ -125,7 +125,7 @@ func TestGeminiRunner_Run_InaccessibleProjectDir_ReturnsError(t *testing.T) { } exec := &storage.Execution{ID: "test-exec"} - err := r.Run(context.Background(), tk, exec) + err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) if err == nil { t.Fatal("expected error for inaccessible project_dir, got nil") @@ -213,7 +213,7 @@ func TestGeminiRunner_Run_ProjectDir_RunsInSandbox(t *testing.T) { } e := &storage.Execution{ID: "sandbox-exec", TaskID: "task-1"} - if err := r.Run(context.Background(), tk, e); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { t.Fatalf("Run: %v", err) } @@ -261,7 +261,7 @@ fi } e := &storage.Execution{ID: "blocked-gemini-exec", TaskID: "task-1"} - err := r.Run(context.Background(), tk, e) + err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)) var blocked *BlockedError if !errors.As(err, &blocked) { @@ -301,7 +301,7 @@ func TestGeminiRunner_Run_ExecError_PreservesSandbox(t *testing.T) { } e := &storage.Execution{ID: "err-gemini-exec", TaskID: "task-1"} - err := r.Run(context.Background(), tk, e) + err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)) if err == nil { t.Fatal("expected error from failing gemini exit") } @@ -352,7 +352,7 @@ func TestGeminiRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { SandboxDir: sandboxDir, } - if err := r.Run(context.Background(), tk, e); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { t.Fatalf("Run with preserved sandbox: %v", err) } @@ -400,7 +400,7 @@ func TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { SandboxDir: staleSandbox, } - if err := r.Run(context.Background(), tk, e); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { t.Fatalf("Run with stale sandbox: %v", err) } @@ -438,7 +438,7 @@ func TestGeminiRunner_Run_NoProjectDir_SkipsSandbox(t *testing.T) { } e := &storage.Execution{ID: "no-pd-gemini", TaskID: "task-nopd"} - if err := r.Run(context.Background(), tk, e); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { t.Fatalf("Run without project_dir: %v", err) } if e.SandboxDir != "" { diff --git a/internal/executor/local.go b/internal/executor/local.go index 5d874c6..e3c9c2c 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -40,7 +40,7 @@ func (r *LocalRunner) ExecLogDir(execID string) string { // 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 { +func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ AgentChannel) error { if r.Client == nil { return fmt.Errorf("local runner: no LLM client configured") } diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go index d8ab678..8aa8791 100644 --- a/internal/executor/local_test.go +++ b/internal/executor/local_test.go @@ -72,7 +72,7 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { } 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(nil, tt.ID, exec)); err != nil { t.Fatalf("Run: %v", err) } @@ -121,7 +121,7 @@ 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, exec)) if err == nil || !strings.Contains(err.Error(), "no LLM client") { t.Errorf("expected 'no LLM client' error, got %v", err) } @@ -134,7 +134,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, exec)) if err == nil || !strings.Contains(err.Error(), "empty instructions") { t.Errorf("expected empty-instructions error, got %v", err) } -- cgit v1.2.3 From 54f6631c28a8b85f6f874e17822549faba916a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:38:06 +0000 Subject: feat(executor): per-task agent MCP server + token registry (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the agent-facing MCP transport foundation: a Registry that mints a per-task bearer token bound to a freshly built MCP server exposing the four agent tools (ask_user, report_summary, spawn_subtask, record_progress), and an HTTP handler (StreamableHTTP) that resolves the token to that server. The server never trusts an agent-supplied task ID — context comes from the token. The default storeChannel now buffers summary and question signals under a mutex (an MCP tool call lands on an HTTP-handler goroutine mid-run), exposing ReportedSummary/PendingQuestion. The pool flushes the buffered summary onto the execution after the run, replacing the runner's direct exec.Summary write and keeping the read race-free. ask_user follows the record-and-resume model: it buffers the question, returns ErrAgentBlocked, and the tool tells the agent to end its turn; the run blocks and resumes later via claude --resume (no live slot held). Tests cover registry lifecycle, in-memory tool dispatch, and HTTP end-to-end with bearer auth (valid token dispatches; invalid token rejected). Not yet wired into the runners or mounted on the API server — next increment. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- go.mod | 14 ++- go.sum | 47 +++++++- internal/executor/agentmcp.go | 160 ++++++++++++++++++++++++++ internal/executor/agentmcp_test.go | 218 ++++++++++++++++++++++++++++++++++++ internal/executor/channel.go | 59 +++++++--- internal/executor/channel_test.go | 42 ++++--- internal/executor/claude_test.go | 8 +- internal/executor/container_test.go | 12 +- internal/executor/executor.go | 12 +- internal/executor/gemini_test.go | 14 +-- internal/executor/local_test.go | 6 +- 11 files changed, 531 insertions(+), 61 deletions(-) create mode 100644 internal/executor/agentmcp.go create mode 100644 internal/executor/agentmcp_test.go diff --git a/go.mod b/go.mod index 54d5b32..fb2828c 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,33 @@ module github.com/thepeterstone/claudomator go 1.25.3 require ( + github.com/BurntSushi/toml v1.6.0 + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.33 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/spf13/cobra v1.10.2 golang.org/x/net v0.49.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.47.0 ) require ( - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.47.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect ) diff --git a/go.sum b/go.sum index a202d97..e4d2f97 100644 --- a/go.sum +++ b/go.sum @@ -5,26 +5,43 @@ github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxh github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -40,6 +57,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -50,6 +69,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -57,6 +78,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -95,16 +118,38 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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 index 76df94f..541694b 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" + "sync" "time" "github.com/thepeterstone/claudomator/internal/event" - "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" ) @@ -50,37 +50,64 @@ type channelStore interface { CreateEvent(e *event.Event) error } -// storeChannel is the default AgentChannel backed by storage. Summary reports -// are buffered onto the execution record so the pool persists them once (with -// its extract/synthesize fallbacks); other signals are written immediately. +// 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 - exec *storage.Execution + + mu sync.Mutex + summary string + summarySet bool + question string + blocked bool } var _ AgentChannel = (*storeChannel)(nil) -func newStoreChannel(store channelStore, taskID string, exec *storage.Execution) *storeChannel { - return &storeChannel{store: store, taskID: taskID, exec: exec} +func newStoreChannel(store channelStore, taskID string) *storeChannel { + return &storeChannel{store: store, taskID: taskID} } -// AskUser on the default (file-transport) channel cannot deliver an answer -// in-session, so it always reports the run as blocked. Question persistence is -// owned by the pool's BlockedError handling. -func (c *storeChannel) AskUser(_ context.Context, _ string) (string, error) { +// 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 onto the execution record. The pool persists -// it (preferring this value over its extract/synthesize fallbacks). +// 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 { - if c.exec != nil { - c.exec.Summary = summary - } + 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{ diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go index 822dd35..250e8eb 100644 --- a/internal/executor/channel_test.go +++ b/internal/executor/channel_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/thepeterstone/claudomator/internal/event" - "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) @@ -29,8 +28,8 @@ func (f *fakeChannelStore) CreateEvent(e *event.Event) error { return nil } -func TestStoreChannel_AskUser_ReturnsBlocked(t *testing.T) { - ch := newStoreChannel(&fakeChannelStore{}, "task-1", &storage.Execution{}) +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) @@ -38,29 +37,36 @@ func TestStoreChannel_AskUser_ReturnsBlocked(t *testing.T) { 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_ReportSummary_BuffersOntoExec(t *testing.T) { - e := &storage.Execution{} - ch := newStoreChannel(&fakeChannelStore{}, "task-1", e) - if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { - t.Fatalf("ReportSummary: %v", err) - } - if e.Summary != "did the thing" { - t.Errorf("expected exec.Summary set, got %q", e.Summary) +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_NilExec(t *testing.T) { - ch := newStoreChannel(&fakeChannelStore{}, "task-1", nil) - if err := ch.ReportSummary(context.Background(), "x"); err != nil { - t.Errorf("expected nil err with nil exec, got %v", err) +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", &storage.Execution{}) + ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "child", Instructions: "do it", @@ -93,7 +99,7 @@ func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { store := &fakeChannelStore{createTaskErr: errors.New("boom")} - ch := newStoreChannel(store, "parent-1", &storage.Execution{}) + ch := newStoreChannel(store, "parent-1") if _, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{Name: "x"}); err == nil { t.Error("expected error from CreateTask") } @@ -101,7 +107,7 @@ func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { store := &fakeChannelStore{} - ch := newStoreChannel(store, "task-1", &storage.Execution{}) + ch := newStoreChannel(store, "task-1") if err := ch.RecordProgress(context.Background(), "halfway done"); err != nil { t.Fatalf("RecordProgress: %v", err) } diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index 0e76260..414f6cf 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -259,7 +259,7 @@ func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) { } // Run completes successfully (binary is "true"). - _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) + _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) // 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 @@ -284,7 +284,7 @@ func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { } exec := &storage.Execution{ID: "test-exec"} - err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) + err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error for inaccessible working_dir, got nil") @@ -732,7 +732,7 @@ func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { SandboxDir: sandboxDir, } - _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) + _ = r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) got, err := os.ReadFile(cwdFile) if err != nil { @@ -778,7 +778,7 @@ func TestClaudeRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { SandboxDir: staleSandbox, } - if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)); err != nil { t.Fatalf("Run with stale sandbox: %v", err) } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 5ee3a3c..9cd80dc 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -139,7 +139,7 @@ func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { } exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"} - err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) + err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to mocked docker failure") } @@ -378,7 +378,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, newStoreChannel(nil, tk.ID, 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 +418,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, newStoreChannel(nil, tk.ID, 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 +504,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, newStoreChannel(nil, tk.ID, 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 +550,7 @@ func TestContainerRunner_ClonesStoryBranch(t *testing.T) { } e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"} - runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, 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 +597,7 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { } e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"} - runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)) + runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) for _, a := range cloneArgs { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 76e67b8..6d6c528 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -356,8 +356,12 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex } } - err = runner.Run(ctx, t, exec, newStoreChannel(p.store, t.ID, 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) @@ -1077,8 +1081,12 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { } // Run the task. - err = runner.Run(ctx, t, exec, newStoreChannel(p.store, t.ID, 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/gemini_test.go b/internal/executor/gemini_test.go index c8f7422..a906f2c 100644 --- a/internal/executor/gemini_test.go +++ b/internal/executor/gemini_test.go @@ -125,7 +125,7 @@ func TestGeminiRunner_Run_InaccessibleProjectDir_ReturnsError(t *testing.T) { } exec := &storage.Execution{ID: "test-exec"} - err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID, exec)) + err := r.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error for inaccessible project_dir, got nil") @@ -213,7 +213,7 @@ func TestGeminiRunner_Run_ProjectDir_RunsInSandbox(t *testing.T) { } e := &storage.Execution{ID: "sandbox-exec", TaskID: "task-1"} - if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)); err != nil { t.Fatalf("Run: %v", err) } @@ -261,7 +261,7 @@ fi } e := &storage.Execution{ID: "blocked-gemini-exec", TaskID: "task-1"} - err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)) + err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) var blocked *BlockedError if !errors.As(err, &blocked) { @@ -301,7 +301,7 @@ func TestGeminiRunner_Run_ExecError_PreservesSandbox(t *testing.T) { } e := &storage.Execution{ID: "err-gemini-exec", TaskID: "task-1"} - err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)) + err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error from failing gemini exit") } @@ -352,7 +352,7 @@ func TestGeminiRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { SandboxDir: sandboxDir, } - if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)); err != nil { t.Fatalf("Run with preserved sandbox: %v", err) } @@ -400,7 +400,7 @@ func TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { SandboxDir: staleSandbox, } - if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)); err != nil { t.Fatalf("Run with stale sandbox: %v", err) } @@ -438,7 +438,7 @@ func TestGeminiRunner_Run_NoProjectDir_SkipsSandbox(t *testing.T) { } e := &storage.Execution{ID: "no-pd-gemini", TaskID: "task-nopd"} - if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID, e)); err != nil { + if err := r.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)); err != nil { t.Fatalf("Run without project_dir: %v", err) } if e.SandboxDir != "" { diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go index 8aa8791..ffe87f9 100644 --- a/internal/executor/local_test.go +++ b/internal/executor/local_test.go @@ -72,7 +72,7 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { } exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} - if err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID, exec)); err != nil { + if err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID)); err != nil { t.Fatalf("Run: %v", err) } @@ -121,7 +121,7 @@ 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, newStoreChannel(nil, tt.ID, 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 +134,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, newStoreChannel(nil, tt.ID, 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) } -- cgit v1.2.3 From 952b7623ee9dceec15099043086622aa2aab4741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:45:16 +0000 Subject: feat(executor,api): wire agent MCP into ContainerRunner + mount /mcp (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContainerRunner now mints a per-task MCP token (from an injected Registry), writes a claude mcp-config into the workspace pointing at the host agent MCP server over host.docker.internal with that bearer, and adds --mcp-config to the in-container claude invocation. The token is revoked when the run ends. After the run, a buffered ask_user (PendingQuestion on the channel) is converted into a BlockedError — the MCP path to BLOCKED — with the file-based question.json kept as a fallback for in-flight tasks started on the old wire. The API server mounts the StreamableHTTP MCP handler at POST/GET/DELETE /mcp when a registry is provided; serve.go constructs one Registry shared by the runners (mint) and the server (resolve). Minting is skipped for gemini agents. Tests: writeMCPConfig output shape, buildInnerCmd flag presence/absence, and an api-level end-to-end MCP tool call through NewServer/Handler proving the route is mounted (and absent without a registry). https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/agentmcp_endpoint_test.go | 104 +++++++++++++++++++++++++++++++++ internal/api/server.go | 13 ++++- internal/api/server_test.go | 4 +- internal/cli/serve.go | 8 ++- internal/executor/channel.go | 15 +++++ internal/executor/container.go | 63 +++++++++++++++++++- internal/executor/container_test.go | 58 ++++++++++++++++-- 7 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 internal/api/agentmcp_endpoint_test.go 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/server.go b/internal/api/server.go index ff3a111..1522b72 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -39,6 +39,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 @@ -100,7 +101,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(), @@ -109,6 +110,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(), @@ -180,6 +182,15 @@ 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) + // The streamable HTTP transport uses POST (messages), GET (SSE stream), + // and DELETE (session end). Register them explicitly so the patterns are + // more specific than the "GET /" catch-all and don't conflict. + s.mux.Handle("POST /mcp", mcpHandler) + s.mux.Handle("GET /mcp", mcpHandler) + s.mux.Handle("DELETE /mcp", mcpHandler) + } s.mux.Handle("GET /", http.FileServerFS(webui.Files)) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f902495..dd6eed5 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 } @@ -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 } diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 459c35b..b23304b 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -79,6 +79,9 @@ func serve(addr 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, // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). @@ -92,6 +95,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, "gemini": &executor.ContainerRunner{ Image: cfg.GeminiImage, @@ -103,6 +107,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, "container": &executor.ContainerRunner{ Image: "claudomator-agent:latest", @@ -114,6 +119,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, } @@ -146,7 +152,7 @@ func serve(addr 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) // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} diff --git a/internal/executor/channel.go b/internal/executor/channel.go index 541694b..8605ffa 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -50,6 +50,21 @@ type channelStore interface { 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 diff --git a/internal/executor/container.go b/internal/executor/container.go index 4269ef1..f0f728c 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 } @@ -307,8 +311,24 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto return fmt.Errorf("writing instructions: %w", err) } + // Per-task MCP back-channel: mint a scoped token bound to this run's channel + // and write an mcp-config pointing the agent at the host agent MCP server. + mcpEnabled := false + if r.Registry != nil && t.Agent.Type != "gemini" { + 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 err := writeMCPConfig(workspace, mcpURL, token); err != nil { + return fmt.Errorf("writing mcp config: %w", err) + } + mcpEnabled = true + } + 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...) @@ -365,7 +385,17 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto e.SessionID = sessionID } - // Check whether the agent left a question before exiting. + // 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) + } + return &BlockedError{QuestionJSON: q, SessionID: e.SessionID, SandboxDir: workspace} + } + + // Check whether the agent left a question before exiting (file fallback for + // in-flight tasks started on the pre-MCP wire). questionFile := filepath.Join(logDir, "question.json") if data, readErr := os.ReadFile(questionFile); readErr == nil { os.Remove(questionFile) // consumed @@ -467,7 +497,30 @@ 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" + +// 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) +} + +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. @@ -491,6 +544,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()} @@ -501,6 +557,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 9cd80dc..86e95e2 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" @@ -53,13 +54,44 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) { } } +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 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 +105,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 +113,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 +147,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) } -- cgit v1.2.3 From 473d80224880dd718cb2612fd49b987cd6097b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 17:38:53 +0000 Subject: feat(executor): MCP-oriented planning preamble, applied by ContainerRunner (Phase 2) Rewrites the planning preamble to point the agent at the four MCP tools (ask_user/report_summary/spawn_subtask/record_progress) instead of the CLAUDOMATOR_QUESTION_FILE / CLAUDOMATOR_SUMMARY_FILE conventions: ask_user ends the turn, report_summary replaces the summary file, spawn_subtask replaces the POST-to-/api/tasks planning step. Git discipline is retained. ContainerRunner previously applied no preamble at all; it now prepends this preamble (via buildAgentInstructions) when the MCP back-channel is active and skip_planning is false, so the in-container agent is actually guided to use the tools. Gemini agents (no MCP) are unaffected. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/executor/container.go | 26 ++++++++++---- internal/executor/container_test.go | 22 ++++++++++++ internal/executor/preamble.go | 67 +++++++++++++++---------------------- internal/executor/preamble_test.go | 30 +++++++++++------ 4 files changed, 89 insertions(+), 56 deletions(-) diff --git a/internal/executor/container.go b/internal/executor/container.go index f0f728c..78c3ed7 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -305,12 +305,6 @@ 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 - instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt") - if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0644); err != nil { - return fmt.Errorf("writing instructions: %w", err) - } - // Per-task MCP back-channel: mint a scoped token bound to this run's channel // and write an mcp-config pointing the agent at the host agent MCP server. mcpEnabled := false @@ -327,6 +321,15 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto 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(instructions), 0644); err != nil { + return fmt.Errorf("writing instructions: %w", err) + } + args := r.buildDockerArgs(workspace, agentHome, e.TaskID) innerCmd := r.buildInnerCmd(t, e, isResume, mcpEnabled) @@ -501,6 +504,17 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) // (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 { diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 86e95e2..3d0887c 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -54,6 +54,28 @@ 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 { 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 && 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 && 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") + } +} -- cgit v1.2.3 From 1ea57a7dbf4d34045a361e9bba9191de06cd1749 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 17:42:45 +0000 Subject: refactor(executor): delete unused ClaudeRunner; keep sandbox helpers (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaudeRunner was never instantiated in production (serve.go wires only ContainerRunner) and was not wired for MCP. Remove it and its CLI/buildArgs/ execOnce tests. The sandbox helpers it defined (sandboxCloneSource, setupSandbox, teardownSandbox) are still used by GeminiRunner, so they move to sandbox.go; their tests (plus the shared initGitRepo helper and the tailFile test) move to sandbox_test.go. No production behavior change — this removes dead code and the last file-based agent wire that lived in ClaudeRunner. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/executor/claude.go | 552 -------------------------- internal/executor/claude_test.go | 810 -------------------------------------- internal/executor/sandbox.go | 187 +++++++++ internal/executor/sandbox_test.go | 366 +++++++++++++++++ 4 files changed, 553 insertions(+), 1362 deletions(-) delete mode 100644 internal/executor/claude.go delete mode 100644 internal/executor/claude_test.go create mode 100644 internal/executor/sandbox.go create mode 100644 internal/executor/sandbox_test.go diff --git a/internal/executor/claude.go b/internal/executor/claude.go deleted file mode 100644 index 0123754..0000000 --- a/internal/executor/claude.go +++ /dev/null @@ -1,552 +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, ch AgentChannel) 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) - _ = ch.ReportSummary(ctx, 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 - _ = ch.ReportSummary(ctx, 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", "-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", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err != nil { - // If rejected due to concurrent push, fetch+rebase and retry once. - 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", "-C", sandboxDir, "pull", "--rebase", "origin", "master").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", "-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", "-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 414f6cf..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, newStoreChannel(nil, tk.ID)) - - // 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, newStoreChannel(nil, tk.ID)) - - 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 ` 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, newStoreChannel(nil, tk.ID)) - - 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, newStoreChannel(nil, tk.ID)); 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/sandbox.go b/internal/executor/sandbox.go new file mode 100644 index 0000000..a9248d1 --- /dev/null +++ b/internal/executor/sandbox.go @@ -0,0 +1,187 @@ +package executor + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// 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", "-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", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err != nil { + // If rejected due to concurrent push, fetch+rebase and retry once. + 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", "-C", sandboxDir, "pull", "--rebase", "origin", "master").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", "-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", "-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 +} diff --git a/internal/executor/sandbox_test.go b/internal/executor/sandbox_test.go new file mode 100644 index 0000000..4893263 --- /dev/null +++ b/internal/executor/sandbox_test.go @@ -0,0 +1,366 @@ +package executor + +import ( + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +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 ` 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) + } +} +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) + } +} + +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) + } +} -- cgit v1.2.3 From e766b4c3ef13181ec6adf90ec4abe9db0129fab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 04:57:37 +0000 Subject: feat(api): chatbot-facing MCP server for task orchestration (Phase 3) Adds a chatbot-facing MCP server mounted at /chatbot/mcp, guarded by the shared API bearer token (new config api_token, wired through SetAPIToken). Tools: submit_task, list_tasks, get_task, get_events, answer_question, accept_task, reject_task, cancel_task. submit_task creates and immediately queues a task, resolving the repository URL from a named project when not given explicitly. To keep the REST API and the MCP surface from drifting, the accept/reject/ cancel/answer operations are extracted into a shared service layer (taskops.go) with typed errors mapped to REST status codes; the existing HTTP handlers now delegate to it. The endpoint is only served when a token is configured and presented as a bearer. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/chatbotmcp.go | 218 ++++++++++++++++++++++++++++++++++ internal/api/chatbotmcp_test.go | 233 ++++++++++++++++++++++++++++++++++++ internal/api/server.go | 125 +++----------------- internal/api/taskops.go | 253 ++++++++++++++++++++++++++++++++++++++++ internal/cli/serve.go | 5 + internal/config/config.go | 1 + 6 files changed, 725 insertions(+), 110 deletions(-) create mode 100644 internal/api/chatbotmcp.go create mode 100644 internal/api/chatbotmcp_test.go create mode 100644 internal/api/taskops.go 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/server.go b/internal/api/server.go index 1522b72..c9fadb9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -191,6 +191,12 @@ func (s *Server) routes() { s.mux.Handle("GET /mcp", mcpHandler) s.mux.Handle("DELETE /mcp", mcpHandler) } + // Chatbot-facing MCP server. Always mounted; the handler refuses to serve + // unless a shared API token is configured and presented as a bearer. + 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.Handle("GET /", http.FileServerFS(webui.Files)) } @@ -282,41 +288,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.UpdateTaskStateBy(taskID, task.StateCancelled, event.ActorUser); 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"` } @@ -324,61 +305,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"}) - return - } - if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"}) + if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil { + writeOpError(w, err) 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}) } @@ -704,40 +634,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.UpdateTaskStateBy(id, task.StateCompleted, event.ActorUser); 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"` } @@ -745,8 +650,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/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/cli/serve.go b/internal/cli/serve.go index b23304b..e545763 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -182,6 +182,11 @@ func serve(addr 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{ diff --git a/internal/config/config.go b/internal/config/config.go index 25187cf..94f3ec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,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"` -- cgit v1.2.3 From 6890bafa65a309b540cbe1562c39df137f202369 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 05:01:05 +0000 Subject: refactor(api): remove the natural-language elaborate REST endpoint (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chatbots now elaborate tasks in-conversation and submit them via the chatbot MCP server, so the POST /api/tasks/elaborate shell-out path is no longer needed. Removes the route, handleElaborateTask, and the task-elaborate-only helpers (buildElaboratePrompt, sanitizeElaboratedTask, readProjectContext, appendRawNarrative, elaborateWith{Claude,Local,Gemini}, and the elaboratedTask/ elaboratedAgent types) plus their dedicated tests. Shared helpers (extractJSON, claude/geminiBinaryPath, claude/geminiJSONResult, elaborateTimeout, the per-IP limiter) are retained — the story-elaborate endpoint and task-validate endpoint still use them. Stories themselves are untouched here; their removal stays in the Phase 8 cleanup pass. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/elaborate.go | 337 ---------------------- internal/api/elaborate_local_test.go | 214 -------------- internal/api/elaborate_test.go | 527 ----------------------------------- internal/api/server.go | 1 - internal/api/server_test.go | 28 -- internal/api/validate_test.go | 21 ++ 6 files changed, 21 insertions(+), 1107 deletions(-) delete mode 100644 internal/api/elaborate_local_test.go delete mode 100644 internal/api/elaborate_test.go 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 := 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/server.go b/internal/api/server.go index c9fadb9..6564280 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -133,7 +133,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) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index dd6eed5..43d91b0 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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/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) -- cgit v1.2.3 From b44c8277465c3b4e03fe4de4dd93850413023b69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 05:05:27 +0000 Subject: feat(api,web): event-stream timeline (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/tasks/{id}/events (seq-ordered, ?since_seq for incremental polling) as the timeline data source, and a per-task Timeline section in the web UI that fetches and renders the observability event stream. New pure, unit-tested JS helpers: formatEventText, renderEventTimeline, fetchTaskEvents. The timeline load is async and guarded on a global fetch so the synchronous renderTaskPanel path (and its DOM-mock unit tests) is unaffected. Verified via Go endpoint tests and node --test (272 JS tests pass). Note: the live in-browser panel render was not exercised in this environment — only unit-level coverage. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/events.go | 38 +++++++++++++ internal/api/events_test.go | 94 ++++++++++++++++++++++++++++++++ internal/api/server.go | 1 + web/app.js | 98 ++++++++++++++++++++++++++++++++++ web/style.css | 45 ++++++++++++++++ web/test/event-timeline.test.mjs | 112 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 internal/api/events.go create mode 100644 internal/api/events_test.go create mode 100644 web/test/event-timeline.test.mjs 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 6564280..a4b7ea1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -144,6 +144,7 @@ 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/agents/status", s.handleGetAgentStatus) diff --git a/web/app.js b/web/app.js index ff8e381..09b545b 100644 --- a/web/app.js +++ b/web/app.js @@ -115,6 +115,89 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi return span; } +// ── Event timeline ────────────────────────────────────────────────────────── +// The observability event stream (GET /api/tasks/{id}/events) replaces the +// ad-hoc question/summary panels. formatEventText turns one event into a human +// line; renderEventTimeline builds the list element. + +// formatEventText returns a one-line human description of a task event. +export function formatEventText(event) { + if (!event) return ''; + const p = event.payload || {}; + switch (event.kind) { + case 'state_change': + return `State: ${p.from || '?'} → ${p.to || '?'}`; + case 'summary': + return `Summary: ${p.text || p.summary || ''}`; + case 'clarification_request': + return `Question: ${p.text || ''}`; + case 'clarification_answer': + return `Answer: ${p.answer || ''}`; + case 'subtask_spawned': + return `Spawned subtask: ${p.name || p.subtask_id || ''}`; + case 'commit_made': + return `Commit: ${p.message || p.hash || ''}`; + case 'cost_report': + return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`; + case 'agent_message': + case 'human_message': + return p.text || p.message || ''; + case 'execution_started': + return 'Execution started'; + case 'execution_ended': + return p.status ? `Execution ended (${p.status})` : 'Execution ended'; + default: + return event.kind || ''; + } +} + +// renderEventTimeline builds a
    from an events array. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const ul = doc.createElement('ul'); + ul.className = 'event-timeline'; + if (!events || events.length === 0) { + const empty = doc.createElement('li'); + empty.className = 'event-timeline__empty'; + empty.textContent = 'No events yet.'; + ul.appendChild(empty); + return ul; + } + for (const event of events) { + const li = doc.createElement('li'); + li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`; + + const actor = doc.createElement('span'); + actor.className = 'event-timeline__actor'; + actor.textContent = event.actor || ''; + li.appendChild(actor); + + const text = doc.createElement('span'); + text.className = 'event-timeline__text'; + text.textContent = formatEventText(event); + li.appendChild(text); + + ul.appendChild(li); + } + return ul; +} + +// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global +// fetch; pass a stub in tests. Returns [] on any error so the UI degrades +// gracefully. +export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl) return []; + try { + const resp = await fetchImpl(`/api/tasks/${taskId}/events`); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -2055,6 +2138,21 @@ export function renderTaskPanel(task, executions) { execSection.appendChild(list); } content.appendChild(execSection); + + // ── Timeline ── + // Observability event stream, loaded asynchronously. Guarded so the sync + // panel render (and its unit tests, which have no fetch) is unaffected. + const timelineSection = makeSection('Timeline'); + const timelineContainer = document.createElement('div'); + timelineContainer.className = 'event-timeline-container'; + timelineSection.appendChild(timelineContainer); + content.appendChild(timelineSection); + if (typeof fetch !== 'undefined') { + fetchTaskEvents(task.id).then(events => { + timelineContainer.innerHTML = ''; + timelineContainer.appendChild(renderEventTimeline(events)); + }); + } } async function handleViewLogs(execId) { diff --git a/web/style.css b/web/style.css index d3b01d0..f4a9d91 100644 --- a/web/style.css +++ b/web/style.css @@ -1989,3 +1989,48 @@ dialog label select:focus { opacity: 0.85; padding: 0.1rem 0; } + +/* Event timeline (task observability stream) */ +.event-timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.event-timeline__item { + display: flex; + align-items: baseline; + gap: 10px; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; + font-size: 0.85rem; +} +.event-timeline__item--state_change { border-left-color: var(--state-queued); } +.event-timeline__item--clarification_request { border-left-color: var(--state-blocked); } +.event-timeline__item--clarification_answer { border-left-color: var(--state-blocked); } +.event-timeline__item--summary { border-left-color: var(--state-completed); } +.event-timeline__item--subtask_spawned { border-left-color: var(--accent); } +.event-timeline__item--execution_started, +.event-timeline__item--execution_ended { border-left-color: var(--state-running); } +.event-timeline__actor { + flex: 0 0 auto; + text-transform: uppercase; + font-size: 0.65rem; + letter-spacing: 0.04em; + color: var(--text-muted); + min-width: 56px; +} +.event-timeline__text { + color: var(--text); + word-break: break-word; +} +.event-timeline__empty { + color: var(--text-muted); + font-size: 0.85rem; + padding: 6px 10px; +} diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs new file mode 100644 index 0000000..20ef61a --- /dev/null +++ b/web/test/event-timeline.test.mjs @@ -0,0 +1,112 @@ +// event-timeline.test.mjs — Unit tests for the event timeline component. +// +// Run with: node --test web/test/event-timeline.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatEventText', () => { + it('formats state_change with from/to', () => { + assert.equal( + formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }), + 'State: QUEUED → RUNNING', + ); + }); + + it('formats summary text', () => { + assert.equal( + formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }), + 'Summary: did the thing', + ); + }); + + it('formats clarification_request and answer', () => { + assert.equal( + formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }), + 'Question: which branch?', + ); + assert.equal( + formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }), + 'Answer: main', + ); + }); + + it('formats subtask_spawned by name', () => { + assert.equal( + formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }), + 'Spawned subtask: write tests', + ); + }); + + it('falls back to kind for unknown events', () => { + assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery'); + }); + + it('handles null/empty defensively', () => { + assert.equal(formatEventText(null), ''); + assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi'); + }); +}); + +describe('renderEventTimeline', () => { + it('renders one item per event with actor and text', () => { + const events = [ + { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } }, + { kind: 'summary', actor: 'agent', payload: { text: 'done' } }, + ]; + const ul = renderEventTimeline(events, makeDoc()); + assert.equal(ul.className, 'event-timeline'); + assert.equal(ul.children.length, 2); + // each item has [actor, text] + assert.equal(ul.children[0].children[0].textContent, 'system'); + assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED'); + assert.equal(ul.children[1].children[1].textContent, 'Summary: done'); + }); + + it('renders an empty-state item for no events', () => { + const ul = renderEventTimeline([], makeDoc()); + assert.equal(ul.children.length, 1); + assert.equal(ul.children[0].className, 'event-timeline__empty'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderEventTimeline([], null), null); + }); +}); + +describe('fetchTaskEvents', () => { + it('returns parsed array on success', async () => { + const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] }); + const events = await fetchTaskEvents('t1', stub); + assert.equal(events.length, 1); + }); + + it('returns [] on non-ok response', async () => { + const stub = async () => ({ ok: false, json: async () => ({}) }); + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when fetch throws', async () => { + const stub = async () => { throw new Error('network'); }; + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when no fetch implementation is available', async () => { + assert.deepEqual(await fetchTaskEvents('t1', null), []); + }); +}); -- cgit v1.2.3 From 42bb6432d3e53042723b43330c22eafbf553e10f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 05:09:10 +0000 Subject: refactor(web): remove the create-task modal from the UI (Phase 3) The observability UI no longer creates tasks; chatbots do that via the chatbot MCP server. Removes the New Task button, the #task-modal (manual form + the now-defunct AI-elaborate textbox + validate panel), their handlers (elaborateTask/validateTask/buildValidatePayload/renderValidationResult/ openTaskModal/closeTaskModal/createTask) and the orphaned newTaskButtonShouldShowOnTab helper plus its test. Verified with node --test (267 JS tests pass) and a JS syntax check. Not browser-verified in this environment. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- web/app.js | 274 -------------------------------------- web/index.html | 45 ------- web/test/new-task-button.test.mjs | 24 ---- 3 files changed, 343 deletions(-) delete mode 100644 web/test/new-task-button.test.mjs diff --git a/web/app.js b/web/app.js index 09b545b..0a66a11 100644 --- a/web/app.js +++ b/web/app.js @@ -465,9 +465,6 @@ export function filterActiveTasks(tasks) { return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state)); } -// The New Task button is always visible regardless of active tab. -export function newTaskButtonShouldShowOnTab(_tab) { return true; } - export function filterTasksByTab(tasks, tab) { if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state)); if (tab === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state)); @@ -1642,152 +1639,6 @@ async function submitAnswer(taskId, questionId, answer, banner) { // ── Elaborate (Draft with AI) ───────────────────────────────────────────────── -async function elaborateTask(prompt, workingDir) { - const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, project_dir: workingDir }), - }); - if (!res.ok) { - let msg = `HTTP ${res.status}`; - try { const body = await res.json(); msg = body.error || msg; } catch {} - throw new Error(msg); - } - return res.json(); -} - -// ── Validate ────────────────────────────────────────────────────────────────── - -async function validateTask(payload) { - const res = await fetch(`${API_BASE}/api/tasks/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - let msg = res.statusText; - try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} - throw new Error(msg); - } - return res.json(); -} - -function buildValidatePayload() { - const f = document.getElementById('task-form'); - const name = f.querySelector('[name="name"]').value; - const instructions = f.querySelector('[name="instructions"]').value; - const repository_url = document.getElementById('repository-url').value; - const container_image = document.getElementById('container-image').value; - const allowedToolsEl = f.querySelector('[name="allowed_tools"]'); - const allowed_tools = allowedToolsEl - ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean) - : []; - return { name, repository_url, agent: { instructions, container_image, allowed_tools } }; -} - -function renderValidationResult(result) { - const container = document.getElementById('validate-result'); - container.removeAttribute('hidden'); - container.dataset.clarity = result.clarity; - - let icon; - if (result.ready === true) { - icon = '✓'; - } else if (result.clarity === 'ambiguous') { - icon = '⚠'; - } else { - icon = '✗'; - } - - container.innerHTML = ''; - - const header = document.createElement('div'); - header.className = 'validate-header'; - const iconSpan = document.createElement('span'); - iconSpan.className = 'validate-icon'; - iconSpan.textContent = icon; - const summarySpan = document.createElement('span'); - summarySpan.textContent = ' ' + (result.summary || ''); - header.append(iconSpan, summarySpan); - container.appendChild(header); - - if (result.questions && result.questions.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'validate-questions'; - for (const q of result.questions) { - const li = document.createElement('li'); - li.className = q.severity === 'blocking' ? 'validate-blocking' : 'validate-minor'; - li.textContent = q.text; - ul.appendChild(li); - } - container.appendChild(ul); - } - - if (result.suggestions && result.suggestions.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'validate-suggestions'; - for (const s of result.suggestions) { - const li = document.createElement('li'); - li.className = 'validate-suggestion'; - li.textContent = s; - ul.appendChild(li); - } - container.appendChild(ul); - } -} - -// ── Task modal ──────────────────────────────────────────────────────────────── - -async function openTaskModal() { - document.getElementById('task-modal').showModal(); -} - -function closeTaskModal() { - document.getElementById('task-modal').close(); - document.getElementById('task-form').reset(); - document.getElementById('elaborate-prompt').value = ''; - const validateResult = document.getElementById('validate-result'); - validateResult.setAttribute('hidden', ''); - validateResult.innerHTML = ''; - validateResult.removeAttribute('data-clarity'); -} - -async function createTask(formData) { - const repository_url = formData.get('repository_url'); - const container_image = formData.get('container_image'); - const elaboratePromptEl = document.getElementById('elaborate-prompt'); - const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : ''; - const body = { - name: formData.get('name'), - description: '', - elaboration_input: elaborationInput || undefined, - repository_url: repository_url, - agent: { - instructions: formData.get('instructions'), - container_image: container_image, - max_budget_usd: parseFloat(formData.get('max_budget_usd')), - type: 'container', - }, - timeout: formData.get('timeout'), - priority: formData.get('priority'), - tags: [], - }; - - const res = await fetch(`${API_BASE}/api/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `HTTP ${res.status}`); - } - - closeTaskModal(); - await poll(); -} - // ── Task side panel ─────────────────────────────────────────────────────────── // Format Go's task.Duration JSON value {"Duration": } to human string. @@ -3545,9 +3396,6 @@ if (typeof document !== 'undefined') { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); - // Task modal - document.getElementById('btn-new-task').addEventListener('click', openTaskModal); - document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); // Push notifications button const btnNotify = document.getElementById('btn-notifications'); @@ -3555,128 +3403,6 @@ if (typeof document !== 'undefined') { btnNotify.addEventListener('click', () => enableNotifications(btnNotify)); } - // Validate button - document.getElementById('btn-validate').addEventListener('click', async () => { - const btn = document.getElementById('btn-validate'); - const resultDiv = document.getElementById('validate-result'); - btn.disabled = true; - btn.textContent = 'Checking…'; - try { - const payload = buildValidatePayload(); - const result = await validateTask(payload); - renderValidationResult(result); - } catch (err) { - resultDiv.removeAttribute('hidden'); - resultDiv.textContent = 'Validation failed: ' + err.message; - } finally { - btn.disabled = false; - btn.textContent = 'Validate Instructions'; - } - }); - - // Draft with AI button - const btnElaborate = document.getElementById('btn-elaborate'); - btnElaborate.addEventListener('click', async () => { - const prompt = document.getElementById('elaborate-prompt').value.trim(); - if (!prompt) { - const form = document.getElementById('task-form'); - // Remove previous error - const prev = form.querySelector('.form-error'); - if (prev) prev.remove(); - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = 'Please enter a description before drafting.'; - form.querySelector('.elaborate-section').appendChild(errEl); - return; - } - - btnElaborate.disabled = true; - btnElaborate.textContent = 'Drafting…'; - - // Remove any previous errors or banners - const form = document.getElementById('task-form'); - form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); - - try { - const repoUrl = document.getElementById('repository-url').value.trim(); - const result = await elaborateTask(prompt, repoUrl); - - // Populate form fields - const f = document.getElementById('task-form'); - if (result.name) - f.querySelector('[name="name"]').value = result.name; - if (result.agent && result.agent.instructions) - f.querySelector('[name="instructions"]').value = result.agent.instructions; - if (result.repository_url || result.agent?.repository_url) { - document.getElementById('repository-url').value = result.repository_url || result.agent.repository_url; - } - if (result.agent && result.agent.container_image) { - document.getElementById('container-image').value = result.agent.container_image; - } - if (result.agent && result.agent.max_budget_usd != null) - f.querySelector('[name="max_budget_usd"]').value = result.agent.max_budget_usd; - if (result.timeout) - f.querySelector('[name="timeout"]').value = result.timeout; - if (result.priority) { - const sel = f.querySelector('[name="priority"]'); - if ([...sel.options].some(o => o.value === result.priority)) { - sel.value = result.priority; - } - } - - // Show success banner - const banner = document.createElement('p'); - banner.className = 'elaborate-banner'; - banner.textContent = 'AI draft ready — review and submit.'; - document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner); - - // Auto-validate after elaboration - try { - const result = await validateTask(buildValidatePayload()); - renderValidationResult(result); - } catch (_) { - // silent - elaboration already succeeded, validation is bonus - } - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Elaboration failed: ${err.message}`; - document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl); - } finally { - btnElaborate.disabled = false; - btnElaborate.textContent = 'Draft with AI ✦'; - } - }); - - document.getElementById('task-form').addEventListener('submit', async e => { - e.preventDefault(); - - // Remove any previous error - const prev = e.target.querySelector('.form-error'); - if (prev) prev.remove(); - - const btn = e.submitter; - btn.disabled = true; - btn.textContent = 'Creating…'; - - try { - const validateResult = document.getElementById('validate-result'); - if (!validateResult.hasAttribute('hidden') && validateResult.dataset.clarity && validateResult.dataset.clarity !== 'clear') { - if (!window.confirm('The validator flagged issues. Create task anyway?')) { - return; - } - } - await createTask(new FormData(e.target)); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = err.message; - e.target.appendChild(errEl); - } finally { - btn.disabled = false; - btn.textContent = 'Create & Queue'; - } - }); // Story modal const storyModal = document.getElementById('story-modal'); diff --git a/web/index.html b/web/index.html index 0632cd7..d3bf81b 100644 --- a/web/index.html +++ b/web/index.html @@ -36,7 +36,6 @@ -