From 3d356970dbc1089ff88380bfaa190f703d478d0b Mon Sep 17 00:00:00 2001 From: corral Date: Sat, 8 Aug 2026 22:31:31 -0300 Subject: [PATCH 01/26] corral: work --- README.md | 13 +- cmd/corral/main.go | 8 + docs/task4-verification.md | 14 +- internal/ocxreviewer/reviewer.go | 331 ++++++++++++++++++++++++++ internal/ocxreviewer/reviewer_test.go | 283 ++++++++++++++++++++++ 5 files changed, 641 insertions(+), 8 deletions(-) create mode 100644 internal/ocxreviewer/reviewer.go create mode 100644 internal/ocxreviewer/reviewer_test.go diff --git a/README.md b/README.md index 23a0537..c079c79 100644 --- a/README.md +++ b/README.md @@ -105,16 +105,18 @@ itself may add the OpenCode tool and agent config to your checkout. ## What counts as evidence -Corral currently wires three completion paths: +Corral currently wires four completion paths: - **Command:** run an argv-style command in the attempt worktree and require exit code `0`. - **JSON Schema:** validate a declared JSON artifact against a schema. - **Default diff:** when no gate is declared, require at least one file diff reported by the driver. Prose alone fails. - -The graph schema also contains a reviewer-gate seam, but the production daemon -does not wire a reviewer implementation yet. +- **Reviewer:** a read-only OpenCode session reviews the attempt's evidence — + objective, prior feedback, transcript, the recorded diff artifact, and check + results — and must conclude `APPROVED`; a `NOT_APPROVED` verdict returns its + note as focused retry feedback. Set `CORRAL_REVIEWER_MODEL` to use a specific + model for review sessions. ## Proof, not promises @@ -194,9 +196,10 @@ The core packages are deliberately small: | `internal/graph` | graph schema, validation, states, ready computation | | `internal/sched` | leases, priority, retries, gates, merge orchestration | | `internal/store` | SQLite event log, materialized nodes, attempts, artifacts | -| `internal/verify` | command, JSON Schema, and diff evidence | +| `internal/verify` | command, JSON Schema, diff, and reviewer evidence | | `internal/worktree` | branch/worktree lifecycle and diff artifacts | | `internal/ocxadapter` | OpenCode sessions and completion reconciliation | +| `internal/ocxreviewer` | OpenCode reviewer sessions for the reviewer gate | | `internal/daemon` | control API, planning, role routing, audit export | | `internal/tui` | terminal dashboard and operator controls | diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 255cf04..48d64ef 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -35,6 +35,7 @@ import ( "corral/internal/daemon" "corral/internal/ocx" "corral/internal/ocxadapter" + "corral/internal/ocxreviewer" "corral/internal/sched" "corral/internal/spike" "corral/internal/store" @@ -170,6 +171,7 @@ func daemonCmd(port int, apiKey string) error { wtm := worktree.NewManager(dir) eng := verify.New(dir) eng.Runner = verify.ExecRunner{} + eng.Reviewer = ocxreviewer.New(oc, ocxreviewer.Options{Model: reviewerModel()}) s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{ Concurrency: 4, Worktrees: wtm, }) @@ -241,6 +243,12 @@ func planTimeout() time.Duration { return 5 * time.Minute } +// reviewerModel overrides the reviewer session model; override with +// CORRAL_REVIEWER_MODEL ("" = the OpenCode server default). +func reviewerModel() string { + return os.Getenv("CORRAL_REVIEWER_MODEL") +} + // statusCmd lists runs through the daemon (the TUI shows the same data). func statusCmd() error { return statusCmdWithDir(dirOf("")) diff --git a/docs/task4-verification.md b/docs/task4-verification.md index 68c9ed6..968f3f8 100644 --- a/docs/task4-verification.md +++ b/docs/task4-verification.md @@ -14,9 +14,11 @@ focused feedback; budgets bound retries; prose alone never completes work. (relative to the worktree) against the schema (santhosh-tekuri jsonschema); missing/invalid files and schema violations produce concrete feedback (first 5 validation errors). - - **reviewer**: injectable `Reviewer` session receives the attempt - transcript + worktree and must conclude APPROVED; rejection note is - the feedback. + - **reviewer**: `internal/ocxreviewer` implements the injectable + `Reviewer` seam on top of OpenCode: a read-only LLM session receives the + attempt's evidence (objective, prior feedback, transcript, recorded diff + artifact and check results), must conclude APPROVED; the rejection note + is the feedback. `CORRAL_REVIEWER_MODEL` overrides the session model. - **default gate** (no method declared): an attempt must have produced at least one diff — agent prose alone cannot mark work complete. - **check nodes**: verdict derived from their own command run carried in @@ -39,6 +41,7 @@ focused feedback; budgets bound retries; prose alone never completes work. | Criterion | Evidence | |---|---| | Command, JSON-schema, reviewer checks | `verify` unit tests: pass/fail + feedback for each kind; check-node verdict from Meta | +| Reviewer approves / rejects with a note | `ocxreviewer` tests: scripted fake LLM server covers APPROVED, NOT_APPROVED with notes, session errors, missing verdicts, timeout; `TestOpenCodeReviewerLive` runs a real review session (gated on `CORRAL_LIVE`) | | Failed verification returns focused feedback | `TestFailThenPassAfterRetryWithFeedback`: gate 1 stderr reaches attempt 2 verbatim | | Retry count, timeout, budget bounded | retries from policy; time budget aborts (Task 2); `TestTokenBudgetBoundsRetries` stops retries after MaxTokens consumed | | Exhausted node becomes blocked or failed | `TestPermanentFailureBlocksDownstream`: failed, dependent blocked, run settles `waiting`, dependent never ran | @@ -52,3 +55,8 @@ focused feedback; budgets bound retries; prose alone never completes work. intervention (used by later tasks). - `TestOpenCodeEvidenceGates` is deterministic because the gates grep for fixed markers the prompt demands, independent of model behavior. +- Reviewer sessions are read-only (write tools removed, bash kept for + tests/diffs), poll the transcript to idle, and parse the verdict from the + last assistant message. The verdict format is fixed in the prompt: + `APPROVED`/`NOT_APPROVED` plus a `Note:` line; anything else fails the + gate with a parse error. diff --git a/internal/ocxreviewer/reviewer.go b/internal/ocxreviewer/reviewer.go new file mode 100644 index 0000000..bbf8c50 --- /dev/null +++ b/internal/ocxreviewer/reviewer.go @@ -0,0 +1,331 @@ +// Package ocxreviewer implements the verify.Reviewer seam on top of +// OpenCode sessions (Task 4's reviewer gate). A reviewer session receives +// the completed attempt's evidence — objective, prior feedback, transcript, +// the recorded diff artifact and any check results — and must conclude with +// an explicit verdict: APPROVED or NOT_APPROVED plus a note. The note +// becomes the gate feedback when the verdict is not approved, so the worker +// knows exactly what to fix. Sessions are read-only: the reviewer may +// inspect the worktree and run tests, but never modify files. +package ocxreviewer + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + "time" + + "corral/internal/adapter" + "corral/internal/ocx" + "corral/internal/verify" +) + +// Options tunes reviewer sessions. Zero values select sane defaults. +type Options struct { + // Model overrides the default model for reviewer sessions + // ("" = the OpenCode server default). + Model string + // Timeout bounds a single review session (default 5m). + Timeout time.Duration + // PollInterval is the transcript poll period while the session runs + // (default 400ms). + PollInterval time.Duration +} + +func (o Options) timeout() time.Duration { + if o.Timeout <= 0 { + return 5 * time.Minute + } + return o.Timeout +} + +func (o Options) poll() time.Duration { + if o.PollInterval <= 0 { + return 400 * time.Millisecond + } + return o.PollInterval +} + +// reviewTools keeps reviewer sessions read-only: editing and other write +// paths are removed while bash and read stay available so the reviewer can +// inspect diffs and run tests, mirroring the corral-reviewer agent. +var reviewTools = map[string]bool{ + "edit": false, + "write": false, + "apply_patch": false, + "websearch": false, + "webfetch": false, + "task": false, + "todowrite": false, + "question": false, + "skill": false, + "lsp": false, +} + +// Driver implements verify.Reviewer for OpenCode sessions. +type Driver struct { + oc *ocx.Client + opts Options + + mu sync.Mutex + clients map[string]*ocx.Client // cwd -> client (worktrees) +} + +func New(oc *ocx.Client, opts Options) *Driver { + return &Driver{ + oc: oc, + opts: opts, + clients: map[string]*ocx.Client{}, + } +} + +// clientFor returns the client bound to a directory (the attempt's +// worktree when isolated), creating it on first use. +func (d *Driver) clientFor(cwd string) *ocx.Client { + if cwd == "" { + return d.oc + } + d.mu.Lock() + defer d.mu.Unlock() + if c, ok := d.clients[cwd]; ok { + return c + } + c := ocx.New(d.oc.Base(), cwd) + d.clients[cwd] = c + return c +} + +// Review runs a reviewer session for the attempt's evidence, waits for the +// session to reach idle, and parses the verdict from the transcript. +func (d *Driver) Review(ctx context.Context, req verify.ReviewRequest) (bool, string, error) { + cwd := req.Worktree + if cwd == "" { + cwd = req.Attempt.Cwd + } + client := d.clientFor(cwd) + + title := "corral/review/" + req.Attempt.NodeID + sess, err := client.CreateSession(ctx, title) + if err != nil { + return false, "", fmt.Errorf("review session: %w", err) + } + prompt := promptFor(req) + if err := client.PromptAsyncWithTools(ctx, sess.ID, prompt, d.opts.Model, reviewTools); err != nil { + return false, "", fmt.Errorf("review prompt: %w", err) + } + + deadline := time.Now().Add(d.opts.timeout()) + var lastPollErr error + for { + select { + case <-ctx.Done(): + return false, "", ctx.Err() + default: + } + msgs, err := client.Messages(ctx, sess.ID, 0) + if err != nil { + lastPollErr = err + } else if term, errName := terminal(msgs); term { + if errName != "" { + return false, "", fmt.Errorf("review session error: %s", errName) + } + return verdict(msgs) + } + if time.Now().After(deadline) { + break + } + select { + case <-time.After(d.opts.poll()): + case <-ctx.Done(): + return false, "", ctx.Err() + } + } + + // Timed out: kill the session so it stops generating, and fail fast. + _ = client.Abort(ctx, sess.ID) + if lastPollErr != nil { + return false, "", fmt.Errorf("review timed out after %s (last poll: %v)", d.opts.timeout(), lastPollErr) + } + return false, "", fmt.Errorf("review timed out after %s (no terminal response)", d.opts.timeout()) +} + +// terminal reports whether the newest assistant message ended the session, +// returning the error name when it terminated with a session error. +func terminal(msgs []ocx.Message) (bool, string) { + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Info.Role != "assistant" { + continue + } + if m.Info.Error != nil { + return true, errorName(m.Info.Error) + } + if m.Info.Finish != nil { + return true, "" + } + return false, "" + } + return false, "" +} + +// verdict scans the transcript's assistant text (newest message first) for +// an explicit APPROVED / NOT_APPROVED verdict and its note. +func verdict(msgs []ocx.Message) (bool, string, error) { + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Info.Role != "assistant" { + continue + } + for _, part := range m.Parts { + var p struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(part, &p) != nil || p.Type != "text" { + continue + } + if approved, note, ok := parseVerdict(p.Text); ok { + return approved, note, nil + } + } + } + return false, "", fmt.Errorf("reviewer produced no explicit verdict") +} + +var verdictRe = regexp.MustCompile(`(?i)NOT[_ ]?APPROVED|APPROVED`) + +// parseVerdict extracts an APPROVED / NOT_APPROVED verdict and the note +// that follows it from the model's reply. The note is the "Note: ..." text +// after the verdict keyword, capped so it stays focused. +func parseVerdict(text string) (approved bool, note string, ok bool) { + loc := verdictRe.FindStringIndex(text) + if loc == nil { + return false, "", false + } + kw := strings.ToUpper(text[loc[0]:loc[1]]) + approved = kw != "NOT_APPROVED" && kw != "NOT APPROVED" + rest := text[loc[1]:] + if idx := strings.Index(strings.ToLower(rest), "note:"); idx >= 0 { + note = strings.TrimSpace(rest[idx+len("note:"):]) + if end := strings.Index(note, "\n\n"); end >= 0 { + note = strings.TrimSpace(note[:end]) + } + note = truncate(note, 2000) + } + return approved, note, true +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// promptFor builds the reviewer prompt from the attempt's evidence. +func promptFor(req verify.ReviewRequest) string { + var b strings.Builder + b.WriteString("You are a corral reviewer. Review the completed attempt's evidence below and return a verdict.\n") + b.WriteString("\nOBJECTIVE:\n" + req.Attempt.Objective) + if req.Attempt.Role != "" { + b.WriteString("\n\nATTEMPT ROLE: " + req.Attempt.Role) + } + if req.Worktree != "" { + b.WriteString("\n\nWORKTREE: " + req.Worktree) + } + if req.Feedback != "" { + b.WriteString("\n\nPRIOR FEEDBACK (from a rejected attempt; confirm it is addressed):\n" + req.Feedback) + } + if d := diffArtifact(req.Messages); d != "" { + b.WriteString("\n\nEVIDENCE — RECORDED DIFF ARTIFACT (file changes from the attempt):\n" + d) + } + if c := checkResults(req.Messages); c != "" { + b.WriteString("\n\nEVIDENCE — CHECK RESULTS (command gates that already ran):\n" + c) + } + if t := transcript(req.Messages); t != "" { + b.WriteString("\n\nEVIDENCE — ATTEMPT TRANSCRIPT:\n" + t) + } + b.WriteString(` +VERDICT: +Reply with EXACTLY these two lines and nothing else. + +APPROVED +Note: + +or + +NOT_APPROVED +Note: +`) + return b.String() +} + +// diffArtifact renders the file changes the attempt recorded (the diff +// artifact evidence) into a compact text block. +func diffArtifact(msgs []adapter.Message) string { + var b strings.Builder + for _, m := range msgs { + for _, d := range m.Diffs { + fmt.Fprintf(&b, "--- %s (%s) +%d -%d\n", d.File, d.Status, d.Additions, d.Deletions) + if d.Patch != "" { + b.WriteString(d.Patch) + b.WriteString("\n") + } + } + } + return strings.TrimSpace(b.String()) +} + +// checkResults renders command-gate results carried in message Meta +// (exit/stdout/stderr) as check-result evidence. +func checkResults(msgs []adapter.Message) string { + var b strings.Builder + for i, m := range msgs { + if m.Meta["exit"] == "" { + continue + } + fmt.Fprintf(&b, "# check %d: exit=%s\n", i, m.Meta["exit"]) + if out := m.Meta["stdout"]; out != "" { + b.WriteString("stdout:\n" + tail(out, 2000) + "\n") + } + if errOut := m.Meta["stderr"]; errOut != "" { + b.WriteString("stderr:\n" + tail(errOut, 2000) + "\n") + } + } + return strings.TrimSpace(b.String()) +} + +// transcript renders the attempt's user/assistant messages. +func transcript(msgs []adapter.Message) string { + var b strings.Builder + for _, m := range msgs { + text := strings.TrimSpace(m.Text) + if text == "" { + continue + } + fmt.Fprintf(&b, "%s: %s\n\n", m.Role, text) + } + return strings.TrimSpace(b.String()) +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "..." + s[len(s)-n:] +} + +func errorName(raw *json.RawMessage) string { + if raw == nil { + return "" + } + var e struct { + Name string `json:"name"` + } + if err := json.Unmarshal(*raw, &e); err != nil { + return "" + } + return e.Name +} diff --git a/internal/ocxreviewer/reviewer_test.go b/internal/ocxreviewer/reviewer_test.go new file mode 100644 index 0000000..e406f7d --- /dev/null +++ b/internal/ocxreviewer/reviewer_test.go @@ -0,0 +1,283 @@ +package ocxreviewer + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "corral/internal/adapter" + "corral/internal/livetest" + "corral/internal/ocx" + "corral/internal/spike" + "corral/internal/verify" +) + +// fakeLLM is a scripted OpenCode server: it records review prompts and +// serves a fixed sequence of transcripts, mirroring the fake-reviewer +// script surface. The last transcript repeats, so a reviewer that keeps +// polling settles on it deterministically. +type fakeLLM struct { + mu sync.Mutex + steps [][]ocx.Message + step int + prompts []string + sessions int +} + +func newFakeLLM(steps ...[]ocx.Message) *fakeLLM { + return &fakeLLM{steps: steps} +} + +func (f *fakeLLM) serve() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + f.sessions++ + _ = json.NewEncoder(w).Encode(ocx.Session{ID: "ses_1", Directory: "proj", Title: "review"}) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt_async"): + var body struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + for _, p := range body.Parts { + f.prompts = append(f.prompts, p.Text) + } + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"): + // Serve the next scripted transcript; the last one repeats so + // a reviewer that keeps polling settles on it deterministically. + if f.step < len(f.steps)-1 { + f.step++ + } + _ = json.NewEncoder(w).Encode(f.steps[f.step]) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func llmBusy() []ocx.Message { + return []ocx.Message{{Info: ocx.MessageInfo{Role: "assistant"}}} +} + +func llmText(text string) ocx.Message { + finish := "stop" + part, _ := json.Marshal(map[string]string{"type": "text", "text": text}) + return ocx.Message{ + Info: ocx.MessageInfo{Role: "assistant", Finish: &finish}, + Parts: []json.RawMessage{part}, + } +} + +func llmError(name string) []ocx.Message { + raw, _ := json.Marshal(map[string]string{"name": name}) + rm := json.RawMessage(raw) + return []ocx.Message{{Info: ocx.MessageInfo{Role: "assistant", Error: &rm}}} +} + +func reviewReq(worktree string) verify.ReviewRequest { + return verify.ReviewRequest{ + Attempt: adapter.Attempt{ + ID: "w1/1", + NodeID: "w1", + Objective: "create manifest.json with a name field", + Role: "worker", + Cwd: worktree, + }, + Worktree: worktree, + Feedback: "manifest.json is missing the name field", + Messages: []adapter.Message{ + {Role: "user", Text: "create manifest.json", Diffs: []adapter.Diff{ + {File: "manifest.json", Patch: "+name: x", Additions: 1, Status: "added"}, + }}, + {Role: "assistant", Finish: "stop", Text: "created manifest.json", Meta: map[string]string{ + "exit": "0", "stdout": "ok", + }}, + }, + } +} + +func TestReviewerApproved(t *testing.T) { + llm := newFakeLLM( + llmBusy(), // session starts streaming: not terminal yet + []ocx.Message{llmText("APPROVED\nNote: manifest.json now has a name field and matches the schema.")}, + ) + srv := llm.serve() + defer srv.Close() + + drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) + approved, note, err := drv.Review(context.Background(), reviewReq(t.TempDir())) + if err != nil { + t.Fatal(err) + } + if !approved { + t.Fatal("approved = false, want true") + } + if !strings.Contains(note, "name field") { + t.Fatalf("note = %q, want the reviewer's justification", note) + } + + if len(llm.prompts) != 1 { + t.Fatalf("review prompts = %d, want 1", len(llm.prompts)) + } + for _, want := range []string{"OBJECTIVE", "create manifest.json", "PRIOR FEEDBACK", "DIFF ARTIFACT", "manifest.json", "CHECK RESULTS", "exit=0"} { + if !strings.Contains(llm.prompts[0], want) { + t.Errorf("prompt missing %q", want) + } + } +} + +func TestReviewerNotApproved(t *testing.T) { + llm := newFakeLLM([]ocx.Message{llmText("NOT_APPROVED\nNote: the manifest is still missing the required count field.")}) + srv := llm.serve() + defer srv.Close() + + drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) + approved, note, err := drv.Review(context.Background(), reviewReq(t.TempDir())) + if err != nil { + t.Fatal(err) + } + if approved { + t.Fatal("approved = true, want false") + } + if !strings.Contains(note, "count field") { + t.Fatalf("note = %q, want rejection feedback", note) + } +} + +func TestReviewerSessionError(t *testing.T) { + llm := newFakeLLM(llmError("MessageAbortedError")) + srv := llm.serve() + defer srv.Close() + + drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) + if _, _, err := drv.Review(context.Background(), reviewReq(t.TempDir())); err == nil { + t.Fatal("session error not surfaced") + } else if !strings.Contains(err.Error(), "MessageAbortedError") { + t.Fatalf("error = %q, want session error name", err) + } +} + +func TestReviewerNoVerdict(t *testing.T) { + llm := newFakeLLM([]ocx.Message{llmText("The changes look fine to me; ship it.")}) + srv := llm.serve() + defer srv.Close() + + drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) + if _, _, err := drv.Review(context.Background(), reviewReq(t.TempDir())); err == nil { + t.Fatal("missing verdict not surfaced") + } else if !strings.Contains(err.Error(), "no explicit verdict") { + t.Fatalf("error = %q, want missing-verdict error", err) + } +} + +func TestReviewerTimeout(t *testing.T) { + llm := newFakeLLM(llmBusy()) // never reaches a terminal message + srv := llm.serve() + defer srv.Close() + + drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 50 * time.Millisecond}) + if _, _, err := drv.Review(context.Background(), reviewReq(t.TempDir())); err == nil { + t.Fatal("timeout not surfaced") + } else if !strings.Contains(err.Error(), "timed out") { + t.Fatalf("error = %q, want timeout error", err) + } +} + +func TestParseVerdict(t *testing.T) { + cases := []struct { + text string + approved bool + note string + ok bool + }{ + {"APPROVED\nNote: good work", true, "good work", true}, + {"NOT_APPROVED\nNote: missing tests", false, "missing tests", true}, + {"NOT APPROVED.\nNote: wrong order.", false, "wrong order.", true}, + {"We approve.\nAPPROVED\nNote: verified by hand.", true, "verified by hand.", true}, + {"no verdict anywhere", false, "", false}, + {"NOT_APPROVED without a note", false, "", true}, + } + for _, c := range cases { + approved, note, ok := parseVerdict(c.text) + if ok != c.ok || approved != c.approved || note != c.note { + t.Errorf("parseVerdict(%q) = (%v, %q, %v), want (%v, %q, %v)", + c.text, approved, note, ok, c.approved, c.note, c.ok) + } + } +} + +func TestPromptForIncludesEvidence(t *testing.T) { + req := reviewReq("/tmp/worktree") + p := promptFor(req) + for _, want := range []string{ + "OBJECTIVE", + "ATTEMPT ROLE: worker", + "WORKTREE: /tmp/worktree", + "PRIOR FEEDBACK", + "DIFF ARTIFACT", + "manifest.json", + "CHECK RESULTS", + "exit=0", + "TRANSCRIPT", + "VERDICT", + "NOT_APPROVED", + } { + if !strings.Contains(p, want) { + t.Errorf("prompt missing %q", want) + } + } +} + +func TestOpenCodeReviewerLive(t *testing.T) { + livetest.SkipIfDisabled(t) + if _, err := exec.LookPath("opencode"); err != nil { + t.Skip("opencode binary not found") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + proj, err := os.MkdirTemp("", "corral-review-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(proj) }) + for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"commit", "-q", "--allow-empty", "-m", "init"}} { + cmd := exec.Command("git", args...) + cmd.Dir = proj + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + if err := os.WriteFile(filepath.Join(proj, "manifest.json"), []byte(`{"name":"x"}`), 0o644); err != nil { + t.Fatal(err) + } + + srv, err := spike.StartServer(ctx, proj, 0, os.Stderr) + if err != nil { + t.Fatalf("start server: %v", err) + } + t.Cleanup(srv.Stop) + + drv := New(ocx.New(srv.Base, proj), Options{PollInterval: 400 * time.Millisecond, Timeout: 8 * time.Minute}) + approved, note, err := drv.Review(ctx, reviewReq(proj)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(note) == "" { + t.Error("reviewer returned an empty note") + } + t.Logf("verdict: approved=%v note=%q", approved, note) +} From a86f6bb1157b1d6223eac5adbf63c58d79a1c478 Mon Sep 17 00:00:00 2001 From: corral Date: Sat, 8 Aug 2026 22:31:31 -0300 Subject: [PATCH 02/26] corral: work --- README.md | 23 +++++++++++++++ cmd/corral/main.go | 43 +++++++++++++++++++++++++-- cmd/corral/main_test.go | 64 +++++++++++++++++++++++++++++++++++++++++ docs/task8-hardening.md | 11 +++++++ 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23a0537..96a6d55 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,29 @@ CORRAL_DAEMON_KEY="$(cat .corral/api.key)" \ corral export > audit.json ``` +### Run-level safeguards + +The daemon ships with run-level safeguards enabled by default. Each is +overridable via an environment variable; a value of `0` disables it. These are +ceilings for runaway runs — normal runs should never hit them. + +| Variable | Default | Behavior | +|---|---|---| +| `CORRAL_BREAKER_MAX_FAILURES` | `5` | Circuit breaker: once `N` node failures occur within the window, the run stops starting new work; pending nodes are blocked (`reason: circuit breaker`) and an operator retry resets the breaker. | +| `CORRAL_BREAKER_WINDOW` | `900` (seconds, 15 min) | Failures are counted within this rolling window. | +| `CORRAL_RUN_MAX_TOKENS` | `1_000_000` | Run-level token budget, accumulated across all finished attempts; once exceeded, pending nodes are blocked (`reason: run budget exceeded`). | +| `CORRAL_RUN_MAX_COST` | `100` (USD) | Run-level cost budget, accumulated across all finished attempts; once exceeded, pending nodes are blocked. | + +Example: + +```sh +CORRAL_BREAKER_MAX_FAILURES=3 \ +CORRAL_BREAKER_WINDOW=600 \ +CORRAL_RUN_MAX_TOKENS=250000 \ +CORRAL_RUN_MAX_COST=50 \ +corral up +``` + ## Development ```sh diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 255cf04..c8f34a9 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -170,9 +170,10 @@ func daemonCmd(port int, apiKey string) error { wtm := worktree.NewManager(dir) eng := verify.New(dir) eng.Runner = verify.ExecRunner{} - s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{ - Concurrency: 4, Worktrees: wtm, - }) + opts := schedOpts(wtm) + s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, opts) + log.Printf("run safeguards: breaker %d failures per %s; run budget %d tokens / $%.2f", + opts.BreakerMaxFailures, opts.BreakerWindow, opts.RunMaxTokens, opts.RunMaxCost) d := daemon.New(st, s, daemon.NewOpenCodePlanner(oc, "", planTimeout()), dir, apiKey) if err := d.Resume(ctx); err != nil { log.Printf("resume: %v", err) @@ -241,6 +242,42 @@ func planTimeout() time.Duration { return 5 * time.Minute } +// schedOpts returns the daemon scheduler options: the fixed +// concurrency/worktree wiring plus run-level safeguards with defaults +// that env vars override (a value of 0 disables a safeguard). +func schedOpts(wtm *worktree.Manager) sched.Options { + return sched.Options{ + Concurrency: 4, + Worktrees: wtm, + BreakerMaxFailures: intEnv("CORRAL_BREAKER_MAX_FAILURES", 5), + BreakerWindow: time.Duration(intEnv("CORRAL_BREAKER_WINDOW", 900)) * time.Second, + RunMaxTokens: intEnv("CORRAL_RUN_MAX_TOKENS", 1_000_000), + RunMaxCost: floatEnv("CORRAL_RUN_MAX_COST", 100), + } +} + +// intEnv returns the named env var as an int, or def when unset or +// unparsable. +func intEnv(name string, def int) int { + if v := os.Getenv(name); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +// floatEnv returns the named env var as a float64, or def when unset or +// unparsable. +func floatEnv(name string, def float64) float64 { + if v := os.Getenv(name); v != "" { + if n, err := strconv.ParseFloat(v, 64); err == nil { + return n + } + } + return def +} + // statusCmd lists runs through the daemon (the TUI shows the same data). func statusCmd() error { return statusCmdWithDir(dirOf("")) diff --git a/cmd/corral/main_test.go b/cmd/corral/main_test.go index ded942e..6d31a55 100644 --- a/cmd/corral/main_test.go +++ b/cmd/corral/main_test.go @@ -259,6 +259,70 @@ func TestUpCmdSpawnsHealthyDaemon(t *testing.T) { _ = exec.Command("pkill", "-f", "corral daemon --port "+fmt.Sprint(port)).Run() } +func TestSchedOptsDefaultsAndOverrides(t *testing.T) { + for _, name := range []string{ + "CORRAL_BREAKER_MAX_FAILURES", "CORRAL_BREAKER_WINDOW", + "CORRAL_RUN_MAX_TOKENS", "CORRAL_RUN_MAX_COST", + } { + t.Setenv(name, "") + } + o := schedOpts(nil) + if o.Concurrency != 4 { + t.Fatalf("Concurrency = %d, want 4 (fixed wiring)", o.Concurrency) + } + if o.Worktrees != nil { + t.Fatalf("Worktrees = %v, want nil (as passed in)", o.Worktrees) + } + if o.BreakerMaxFailures != 5 { + t.Fatalf("BreakerMaxFailures = %d, want default 5", o.BreakerMaxFailures) + } + if o.BreakerWindow != 15*time.Minute { + t.Fatalf("BreakerWindow = %s, want default 15m", o.BreakerWindow) + } + if o.RunMaxTokens != 1_000_000 { + t.Fatalf("RunMaxTokens = %d, want default 1000000", o.RunMaxTokens) + } + if o.RunMaxCost != 100 { + t.Fatalf("RunMaxCost = %v, want default 100", o.RunMaxCost) + } + + // Overrides apply. + t.Setenv("CORRAL_BREAKER_MAX_FAILURES", "3") + t.Setenv("CORRAL_BREAKER_WINDOW", "60") + t.Setenv("CORRAL_RUN_MAX_TOKENS", "5000") + t.Setenv("CORRAL_RUN_MAX_COST", "0.25") + o = schedOpts(nil) + if o.BreakerMaxFailures != 3 { + t.Fatalf("BreakerMaxFailures = %d, want 3", o.BreakerMaxFailures) + } + if o.BreakerWindow != time.Minute { + t.Fatalf("BreakerWindow = %s, want 1m", o.BreakerWindow) + } + if o.RunMaxTokens != 5000 { + t.Fatalf("RunMaxTokens = %d, want 5000", o.RunMaxTokens) + } + if o.RunMaxCost != 0.25 { + t.Fatalf("RunMaxCost = %v, want 0.25", o.RunMaxCost) + } + + // 0 disables a safeguard rather than falling back to the default. + t.Setenv("CORRAL_RUN_MAX_TOKENS", "0") + t.Setenv("CORRAL_BREAKER_MAX_FAILURES", "0") + o = schedOpts(nil) + if o.RunMaxTokens != 0 || o.BreakerMaxFailures != 0 { + t.Fatalf("zero should disable safeguards: %+v", o) + } + + // Unparsable values fall back to the defaults. + t.Setenv("CORRAL_RUN_MAX_TOKENS", "banana") + t.Setenv("CORRAL_RUN_MAX_COST", "not-a-number") + t.Setenv("CORRAL_BREAKER_WINDOW", "soon") + o = schedOpts(nil) + if o.RunMaxTokens != 1_000_000 || o.RunMaxCost != 100 || o.BreakerWindow != 15*time.Minute { + t.Fatalf("unparsable env should fall back to defaults: %+v", o) + } +} + func TestVersionAtLeast(t *testing.T) { cases := []struct { v, min string diff --git a/docs/task8-hardening.md b/docs/task8-hardening.md index 2ff40ae..bc6605c 100644 --- a/docs/task8-hardening.md +++ b/docs/task8-hardening.md @@ -26,6 +26,17 @@ Status: **DONE** — all acceptance criteria covered with tests. new work starts (fixed ordering bug where new nodes could start the step after a budget/breaker trip). - Per-node time/token/cost budgets from Tasks 2/4 unchanged. +- **Daemon defaults**: the production daemon wires these safeguards with sane + defaults, each overridable via env vars (a value of `0` disables it): + - `CORRAL_BREAKER_MAX_FAILURES` (default `5`) and `CORRAL_BREAKER_WINDOW` + (default `900`s / 15 min) — the circuit breaker trips after that many node + failures within the window, blocking pending nodes until an operator retry + resets it. + - `CORRAL_RUN_MAX_TOKENS` (default `1_000_000`) and `CORRAL_RUN_MAX_COST` + (default `$100`) — run-level budgets accumulated across finished attempts; + pending nodes block once exceeded. Wired in `cmd/corral/main.go` via + `schedOpts`, keeping the fixed `Concurrency`/`Worktrees` wiring intact. + Covered by `TestSchedOptsDefaultsAndOverrides`. - **Secrets hygiene**: `store.Redact` strips bearer tokens, api keys, passwords/secrets/tokens and `sk-…` patterns at the persistence boundary (attempt evidence, event payloads, artifact content). Verified From 16252ccca9e87725876430039998fee8712f9205 Mon Sep 17 00:00:00 2001 From: corral Date: Mon, 10 Aug 2026 16:46:34 -0300 Subject: [PATCH 03/26] corral: work --- .opencode/tools/corral.ts | 108 ++++++++++++++++++- README.md | 5 +- docs/task6-plugin.md | 17 ++- example/opencode.json | 1 + internal/assets/corral.ts | 108 ++++++++++++++++++- internal/assets/opencode.json | 1 + internal/daemon/daemon.go | 158 ++++++++++++++++++++++++++- internal/daemon/daemon_test.go | 171 ++++++++++++++++++++++++++++++ internal/daemon/hardening_test.go | 1 + internal/daemon/openapi.go | 28 +++++ internal/sched/gates_test.go | 35 ++++++ internal/sched/sched.go | 99 +++++++++++------ internal/store/store.go | 79 +++++++++++--- internal/store/store_test.go | 79 ++++++++++++++ 14 files changed, 830 insertions(+), 60 deletions(-) diff --git a/.opencode/tools/corral.ts b/.opencode/tools/corral.ts index 37551b6..dcd5fe3 100644 --- a/.opencode/tools/corral.ts +++ b/.opencode/tools/corral.ts @@ -57,6 +57,23 @@ async function call(path: string, body?: unknown, role?: string) { return JSON.stringify(await res.json(), null, 2) } +// unwrapGraph accepts both the raw inner graph object and the full +// corral_plan output (wrapped as {"graph": ...}). A leading single-key +// {"graph": ...} wrapper is unwrapped so the daemon never receives a +// double-wrapped, empty graph (version 0, nodes null) that completes +// instantly with zero nodes. +function unwrapGraph(parsed: unknown): unknown { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return parsed + } + const obj = parsed as Record + const keys = Object.keys(obj) + if (keys.length === 1 && keys[0] === "graph") { + return obj.graph + } + return parsed +} + export const plan = tool({ description: "Plan a corral task graph from a goal statement (planner agent).", args: { goal: tool.schema.string().describe("The goal to plan a graph for") }, @@ -67,15 +84,23 @@ export const plan = tool({ export const start = tool({ description: "Start a corral run from an approved graph.", - args: { graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)") }, + args: { + graph: tool.schema.string().describe("Graph JSON: the raw graph, or the full corral_plan output wrapped as {\"graph\": ...}"), + autoApproveGates: tool.schema.boolean().optional().describe("Approve human gates automatically instead of waiting for operator approval"), + }, async execute(args, context) { - let graph: unknown + let parsed: unknown try { - graph = JSON.parse(args.graph) + parsed = JSON.parse(args.graph) } catch { return "error: graph is not valid JSON" } - return call("/api/runs", { graph }, roleFor(context.agent)) + const graph = unwrapGraph(parsed) + const body: Record = { graph } + if (args.autoApproveGates) { + body.autoApproveGates = true + } + return call("/api/runs", body, roleFor(context.agent)) }, }) @@ -147,3 +172,78 @@ export const steer = tool({ }, roleFor(context.agent)) }, }) + +// sseData joins the data payload of one SSE frame, or null when the +// frame carries no data (e.g. a heartbeat comment). +function sseData(frame: string): string | null { + const lines = frame.split("\n").filter((l) => l.startsWith("data:")) + if (lines.length === 0) return null + return lines.map((l) => l.slice(5).trimStart()).join("\n") +} + +export const watch = tool({ + description: + "Wait for the next delta of a corral run — a node transition, a human gate awaiting approval, or the run finishing — from the daemon SSE stream. Returns the first event, or a message when none arrive within the timeout.", + args: { + runID: tool.schema.string().describe("Run id"), + after: tool.schema.number().optional().describe("Only report events with a sequence number greater than this (resume from a previous watch or status call)"), + timeout: tool.schema.number().optional().describe("Max seconds to wait for an event (default 30)"), + }, + async execute(args, context) { + const runID = encodeURIComponent(args.runID) + const after = args.after ?? 0 + const timeout = args.timeout ?? 30 + const key = await loadKey() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout * 1000) + try { + const res = await fetch(`${DAEMON}/api/runs/${runID}/watch?after=${after}`, { + headers: { + "X-Corral-Role": roleFor(context.agent), + ...(key ? { Authorization: `Bearer ${key}` } : {}), + }, + signal: AbortSignal.any([controller.signal, context.abort]), + }) + if (res.status === 401) { + return "error 401: API key mismatch — restart the daemon so it uses .corral/api.key (corral up)" + } + if (!res.ok) { + return `error ${res.status}: ${await res.text()}` + } + if (!res.body) { + return "error: daemon returned no event stream" + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + const data = sseData(frame) + if (data === null) continue + await reader.cancel().catch(() => {}) + try { + return JSON.stringify(JSON.parse(data), null, 2) + } catch { + return `event (raw): ${data}` + } + } + } + return "error: daemon closed the event stream without an event" + } catch (err) { + if (controller.signal.aborted) { + return `no events within ${timeout}s (after seq ${after})` + } + if (context.abort.aborted) { + return "cancelled: corral_watch aborted" + } + return `error: corral daemon is not running at ${DAEMON}. Start it with: corral up` + } finally { + clearTimeout(timer) + } + }, +}) diff --git a/README.md b/README.md index 3e1ff88..99a03e4 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ Inside OpenCode: 1. Switch to `corral-planner` and ask: `plan a graph to `. 2. Review the returned graph. 3. Switch to `corral-orchestrator` and ask it to start that graph. -4. Follow progress with `corral_status`; approve, reject, retry, cancel, or - steer nodes when needed. +4. Follow progress with `corral_status` / `corral_watch`; approve, reject, + retry, cancel, or steer nodes when needed. `corral_start` accepts an + optional `autoApproveGates` flag to skip operator approval on gates. Or follow the same run from the terminal: diff --git a/docs/task6-plugin.md b/docs/task6-plugin.md index 4d0444d..22b63f1 100644 --- a/docs/task6-plugin.md +++ b/docs/task6-plugin.md @@ -14,9 +14,14 @@ flow is verified end-to-end against a real OpenCode server. agent roles, default acceptance criteria) before `graph.Validate`. - `POST /api/runs` — start a run from a graph; run loops live on the daemon context (not request context — bug found and fixed) and - persist via SQLite. + persist via SQLite. Accepts `autoApproveGates` (stored on the run and + exposed by `GET /api/runs/{id}`); gates then pass without operator + approval. - `GET /api/runs`, `GET /api/runs/{id}` — follow execution (states, attempts, event log). + - `GET /api/runs/{id}/watch` — Server-Sent Events stream of run deltas + (node transitions, gates awaiting approval, run done) from an `after` + cursor; powers `corral_watch`. - `approve` / `reject` / `cancel` / `retry` / `steer` per node — including `RetryNode` (blocked→ready, retry_wait→ready, failed→ready operator override with retry budget reset) and run-loop restart after @@ -29,9 +34,13 @@ flow is verified end-to-end against a real OpenCode server. - `cmd/corral` — `corral daemon [--port 4519] [--key TOKEN]`: embeds `opencode serve`, wires store + adapter + worktrees + verifier. - `.opencode/tools/corral.ts` — the thin plugin: `corral_plan`, - `corral_start`, `corral_status`, `corral_approve`, `corral_reject`, - `corral_cancel`, `corral_retry`, `corral_steer`, calling the daemon and - mapping the session agent (`corral-*`) to a role. + `corral_start` (accepts the raw graph *or* the full `corral_plan` output, + unwrapping a leading `{"graph": ...}` wrapper, plus an optional + `autoApproveGates` flag), `corral_status`, `corral_watch` (blocks on the + daemon SSE stream and returns the first run delta — node transition, gate + awaiting approval, or run done — or times out), `corral_approve`, + `corral_reject`, `corral_cancel`, `corral_retry`, `corral_steer`, calling + the daemon and mapping the session agent (`corral-*`) to a role. - `example/opencode.json` — agent role configuration using OpenCode's per-agent permissions: orchestrator (deny edit/bash, allow corral_*), planner (read-only + corral_plan), worker (ask edits/bash), reviewer diff --git a/example/opencode.json b/example/opencode.json index 94e05f6..59637e4 100644 --- a/example/opencode.json +++ b/example/opencode.json @@ -11,6 +11,7 @@ "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", + "corral_watch": "allow", "corral_approve": "allow", "corral_reject": "allow", "corral_cancel": "allow", diff --git a/internal/assets/corral.ts b/internal/assets/corral.ts index 37551b6..dcd5fe3 100644 --- a/internal/assets/corral.ts +++ b/internal/assets/corral.ts @@ -57,6 +57,23 @@ async function call(path: string, body?: unknown, role?: string) { return JSON.stringify(await res.json(), null, 2) } +// unwrapGraph accepts both the raw inner graph object and the full +// corral_plan output (wrapped as {"graph": ...}). A leading single-key +// {"graph": ...} wrapper is unwrapped so the daemon never receives a +// double-wrapped, empty graph (version 0, nodes null) that completes +// instantly with zero nodes. +function unwrapGraph(parsed: unknown): unknown { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return parsed + } + const obj = parsed as Record + const keys = Object.keys(obj) + if (keys.length === 1 && keys[0] === "graph") { + return obj.graph + } + return parsed +} + export const plan = tool({ description: "Plan a corral task graph from a goal statement (planner agent).", args: { goal: tool.schema.string().describe("The goal to plan a graph for") }, @@ -67,15 +84,23 @@ export const plan = tool({ export const start = tool({ description: "Start a corral run from an approved graph.", - args: { graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)") }, + args: { + graph: tool.schema.string().describe("Graph JSON: the raw graph, or the full corral_plan output wrapped as {\"graph\": ...}"), + autoApproveGates: tool.schema.boolean().optional().describe("Approve human gates automatically instead of waiting for operator approval"), + }, async execute(args, context) { - let graph: unknown + let parsed: unknown try { - graph = JSON.parse(args.graph) + parsed = JSON.parse(args.graph) } catch { return "error: graph is not valid JSON" } - return call("/api/runs", { graph }, roleFor(context.agent)) + const graph = unwrapGraph(parsed) + const body: Record = { graph } + if (args.autoApproveGates) { + body.autoApproveGates = true + } + return call("/api/runs", body, roleFor(context.agent)) }, }) @@ -147,3 +172,78 @@ export const steer = tool({ }, roleFor(context.agent)) }, }) + +// sseData joins the data payload of one SSE frame, or null when the +// frame carries no data (e.g. a heartbeat comment). +function sseData(frame: string): string | null { + const lines = frame.split("\n").filter((l) => l.startsWith("data:")) + if (lines.length === 0) return null + return lines.map((l) => l.slice(5).trimStart()).join("\n") +} + +export const watch = tool({ + description: + "Wait for the next delta of a corral run — a node transition, a human gate awaiting approval, or the run finishing — from the daemon SSE stream. Returns the first event, or a message when none arrive within the timeout.", + args: { + runID: tool.schema.string().describe("Run id"), + after: tool.schema.number().optional().describe("Only report events with a sequence number greater than this (resume from a previous watch or status call)"), + timeout: tool.schema.number().optional().describe("Max seconds to wait for an event (default 30)"), + }, + async execute(args, context) { + const runID = encodeURIComponent(args.runID) + const after = args.after ?? 0 + const timeout = args.timeout ?? 30 + const key = await loadKey() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout * 1000) + try { + const res = await fetch(`${DAEMON}/api/runs/${runID}/watch?after=${after}`, { + headers: { + "X-Corral-Role": roleFor(context.agent), + ...(key ? { Authorization: `Bearer ${key}` } : {}), + }, + signal: AbortSignal.any([controller.signal, context.abort]), + }) + if (res.status === 401) { + return "error 401: API key mismatch — restart the daemon so it uses .corral/api.key (corral up)" + } + if (!res.ok) { + return `error ${res.status}: ${await res.text()}` + } + if (!res.body) { + return "error: daemon returned no event stream" + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + const data = sseData(frame) + if (data === null) continue + await reader.cancel().catch(() => {}) + try { + return JSON.stringify(JSON.parse(data), null, 2) + } catch { + return `event (raw): ${data}` + } + } + } + return "error: daemon closed the event stream without an event" + } catch (err) { + if (controller.signal.aborted) { + return `no events within ${timeout}s (after seq ${after})` + } + if (context.abort.aborted) { + return "cancelled: corral_watch aborted" + } + return `error: corral daemon is not running at ${DAEMON}. Start it with: corral up` + } finally { + clearTimeout(timer) + } + }, +}) diff --git a/internal/assets/opencode.json b/internal/assets/opencode.json index 94e05f6..59637e4 100644 --- a/internal/assets/opencode.json +++ b/internal/assets/opencode.json @@ -11,6 +11,7 @@ "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", + "corral_watch": "allow", "corral_approve": "allow", "corral_reject": "allow", "corral_cancel": "allow", diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5dce54e..eaf4e22 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "sort" + "strconv" "strings" "sync" "time" @@ -106,6 +107,7 @@ func (d *Daemon) Handler() http.Handler { mux.HandleFunc("POST /api/runs", d.role(RoleOrchestrator, RoleOperator)(d.handleCreateRun)) mux.HandleFunc("GET /api/runs", d.handleListRuns) mux.HandleFunc("GET /api/runs/{id}", d.handleGetRun) + mux.HandleFunc("GET /api/runs/{id}/watch", d.handleWatchRun) mux.HandleFunc("POST /api/runs/{id}/approve", d.role(RoleOperator, RoleOrchestrator)(d.handleApprove)) mux.HandleFunc("POST /api/runs/{id}/reject", d.role(RoleOperator, RoleOrchestrator)(d.handleReject)) mux.HandleFunc("POST /api/runs/{id}/cancel", d.role(RoleOperator, RoleOrchestrator)(d.handleCancel)) @@ -184,7 +186,8 @@ func (d *Daemon) handlePlan(w http.ResponseWriter, r *http.Request) { func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { var req struct { - Graph *graph.Graph `json:"graph"` + Graph *graph.Graph `json:"graph"` + AutoApproveGates bool `json:"autoApproveGates"` } if err := readJSON(r, &req); err != nil || req.Graph == nil { http.Error(w, "graph required", http.StatusBadRequest) @@ -192,7 +195,7 @@ func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() runID := "run_" + randID(6) - h, err := d.sched.Create(ctx, runID, req.Graph) + h, err := d.sched.CreateWithOptions(ctx, runID, req.Graph, sched.RunOptions{AutoApproveGates: req.AutoApproveGates}) if err != nil { http.Error(w, "invalid graph: "+err.Error(), http.StatusUnprocessableEntity) return @@ -275,7 +278,8 @@ func (d *Daemon) handleGetRun(w http.ResponseWriter, r *http.Request) { } resp := map[string]any{ "runID": id, "status": ru.Status, "graph": ru.Graph, - "events": events, "attempts": attempts, + "autoApproveGates": ru.AutoApproveGates, + "events": events, "attempts": attempts, } if h, ok := d.runs[id]; ok { states := map[string]string{} @@ -290,6 +294,154 @@ func (d *Daemon) handleGetRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// watchFrame is one SSE frame of the run watch stream. Every frame is a +// delta: a store event (enriched with gate/awaitingApproval when the node +// is a human gate) or the terminal run-done notification. +type watchFrame struct { + Seq int64 `json:"seq"` + Type string `json:"type"` // "event" | "done" + RunID string `json:"runID"` + Event string `json:"event,omitempty"` + NodeID string `json:"nodeID,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + AttemptID string `json:"attemptID,omitempty"` + Gate bool `json:"gate,omitempty"` + AwaitingApproval bool `json:"awaitingApproval,omitempty"` + Status string `json:"status,omitempty"` + Payload string `json:"payload,omitempty"` +} + +// handleWatchRun streams the run's event log as Server-Sent Events, one +// frame per delta after the `after` sequence number (default 0). Frames +// carry node transitions, flag human gates awaiting approval, and emit a +// final "done" frame once the run settles. The stream closes on client +// disconnect or when the run is done and every pending event was sent. +func (d *Daemon) handleWatchRun(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := r.PathValue("id") + if _, err := d.st.Run(ctx, id); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + after := int64(0) + if v := r.URL.Query().Get("after"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + after = n + } + } + + // Which nodes are human gates, so transitions to running can be + // flagged as awaiting operator approval. + isGate := map[string]bool{} + if ru, err := d.st.Run(ctx, id); err == nil { + for _, n := range ru.Graph.Nodes { + isGate[string(n.ID)] = n.Type == graph.NodeHuman + } + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + fl.Flush() + + writeSSE := func(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil { + return err + } + fl.Flush() + return nil + } + + doneSent := false + for { + select { + case <-ctx.Done(): + return + case <-d.ctx.Done(): + return + default: + } + events, err := d.st.Events(ctx, id) + if err != nil { + return + } + last := after + for _, ev := range events { + if ev.Seq <= after { + continue + } + last = ev.Seq + frame := watchFrame{ + Seq: ev.Seq, + Type: "event", + RunID: id, + Event: string(ev.Type), + NodeID: ev.NodeID, + From: string(ev.From), + To: string(ev.To), + AttemptID: ev.AttemptID, + Payload: string(ev.Payload), + } + if ev.NodeID != "" && isGate[ev.NodeID] { + frame.Gate = true + if ev.Type == store.EventTransition && ev.To == graph.StateRunning { + frame.AwaitingApproval = true + } + } + if err := writeSSE(frame); err != nil { + return + } + } + after = last + + if d.runDone(ctx, id) && !doneSent { + doneSent = true + if err := writeSSE(watchFrame{ + Seq: after, + Type: "done", + RunID: id, + Status: d.runStatus(ctx, id), + }); err != nil { + return + } + return + } + time.Sleep(250 * time.Millisecond) + } +} + +// runDone reports whether the run has settled (completed or waiting for a +// human decision). In-memory handles expose Done(); persisted runs without +// a live handle are done when their stored status is terminal. +func (d *Daemon) runDone(ctx context.Context, id string) bool { + d.mu.Lock() + h, live := d.runs[id] + d.mu.Unlock() + if live { + return h.Done() + } + ru, err := d.st.Run(ctx, id) + return err == nil && (ru.Status == "completed" || ru.Status == "waiting") +} + +func (d *Daemon) runStatus(ctx context.Context, id string) string { + ru, err := d.st.Run(ctx, id) + if err != nil { + return "" + } + return ru.Status +} + func (d *Daemon) nodeAction(w http.ResponseWriter, r *http.Request, fn func(ctx context.Context, id graph.NodeID) error) { var req struct { NodeID string `json:"nodeID"` diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 938e7f5..9e1de79 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -1,9 +1,11 @@ package daemon_test import ( + "bufio" "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -299,6 +301,175 @@ func (f *fakePlanner) Plan(_ context.Context, _ string) (*graph.Graph, error) { var _ = daemon.RoleOperator +// TestAutoApproveGatesThroughAPI creates a run with autoApproveGates and +// verifies the flag is stored, exposed by GET /api/runs/{id}, and that the +// gate completes without any operator approval. +func TestAutoApproveGatesThroughAPI(t *testing.T) { + a, _, _, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 100 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{ + workerNode("w1", "a.txt", "A1"), + gateNode("gate", "w1"), + }} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g, "autoApproveGates": true}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + + // The flag is exposed on the run detail. + code, body = a.do("operator", http.MethodGet, "/api/runs/"+created.RunID, nil) + if code != http.StatusOK || !strings.Contains(body, `"autoApproveGates":true`) { + t.Fatalf("autoApproveGates not exposed: %d %s", code, body) + } + // No operator approval needed: the gate completes on its own and the + // run settles without any approve call. + a.waitState(t, "", created.RunID, "gate", graph.StateDone, 30*time.Second) + code, body = a.do("operator", http.MethodGet, "/api/runs/"+created.RunID, nil) + if code != http.StatusOK || !strings.Contains(body, `"done":true`) { + t.Fatalf("run not done: %d %s", code, body) + } +} + +// TestWatchStreamsEvents drives the run through the SSE watch stream: it +// must report the gate awaiting approval, and a done frame once the run +// completes after the operator approves. +func TestWatchStreamsEvents(t *testing.T) { + a, _, _, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 100 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{ + workerNode("w1", "a.txt", "A1"), + gateNode("gate", "w1"), + }} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + + resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/watch") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("content-type = %q, want text/event-stream", ct) + } + r := bufio.NewReader(resp.Body) + + // The worker runs and the gate parks awaiting approval — the stream + // must flag it. + f, err := sseUntil(r, 30*time.Second, func(m map[string]any) bool { + g, _ := m["gate"].(bool) + aa, _ := m["awaitingApproval"].(bool) + return g && aa + }) + if err != nil { + t.Fatalf("gate frame: %v", err) + } + if f["nodeID"] != "gate" || f["to"] != "running" { + t.Fatalf("gate frame = %v, want nodeID gate to running", f) + } + + // Approve via the API; the run completes and the stream emits done. + code, body = a.do("operator", http.MethodPost, "/api/runs/"+created.RunID+"/approve", map[string]any{"nodeID": "gate"}) + if code != http.StatusOK { + t.Fatalf("approve: %d %s", code, body) + } + f, err = sseUntil(r, 30*time.Second, func(m map[string]any) bool { return m["type"] == "done" }) + if err != nil { + t.Fatalf("done frame: %v", err) + } + if f["status"] != "completed" { + t.Fatalf("done status = %v, want completed", f["status"]) + } +} + +// TestWatchReportsDoneForSettledRun opens the watch stream after a run +// already settled: with no pending events the endpoint emits an immediate +// done frame (and the `after` cursor is honored). +func TestWatchReportsDoneForSettledRun(t *testing.T) { + a, _, _, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 50 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + a.waitState(t, "", created.RunID, "w1", graph.StateDone, 30*time.Second) + + resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/watch?after=999999") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + r := bufio.NewReader(resp.Body) + f, err := sseUntil(r, 10*time.Second, func(m map[string]any) bool { return m["type"] == "done" }) + if err != nil { + t.Fatalf("done frame: %v", err) + } + if f["status"] != "completed" { + t.Fatalf("done status = %v, want completed", f["status"]) + } +} + +// sseFrame reads one SSE data frame (data lines up to the blank line) +// and parses it as JSON. +func sseFrame(r *bufio.Reader, timeout time.Duration) (map[string]any, error) { + type res struct { + m map[string]any + err error + } + ch := make(chan res, 1) + go func() { + var data []string + for { + line, err := r.ReadString('\n') + if err != nil { + ch <- res{err: err} + return + } + trimmed := strings.TrimSpace(line) + if trimmed == "" && len(data) > 0 { + var m map[string]any + if err := json.Unmarshal([]byte(strings.Join(data, "")), &m); err != nil { + ch <- res{err: fmt.Errorf("sse decode: %w", err)} + return + } + ch <- res{m: m} + return + } + if strings.HasPrefix(trimmed, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) + } + } + }() + select { + case r := <-ch: + return r.m, r.err + case <-time.After(timeout): + return nil, fmt.Errorf("timed out waiting for SSE frame") + } +} + +// sseUntil consumes SSE frames until one satisfies pred. +func sseUntil(r *bufio.Reader, timeout time.Duration, pred func(map[string]any) bool) (map[string]any, error) { + deadline := time.Now().Add(timeout) + for { + frame, err := sseFrame(r, time.Until(deadline)) + if err != nil { + return nil, err + } + if pred(frame) { + return frame, nil + } + } +} + func TestPermissionThroughAPI(t *testing.T) { a, d, _, drv := setupDaemon(t, "") workdir := d.Dir() diff --git a/internal/daemon/hardening_test.go b/internal/daemon/hardening_test.go index 437b884..58b89f8 100644 --- a/internal/daemon/hardening_test.go +++ b/internal/daemon/hardening_test.go @@ -82,6 +82,7 @@ func TestOpenAPIContract(t *testing.T) { paths, _ := doc["paths"].(map[string]any) registered := []string{ "/api/health", "/api/plan", "/api/runs", "/api/runs/{id}", + "/api/runs/{id}/watch", "/api/runs/{id}/approve", "/api/runs/{id}/reject", "/api/runs/{id}/cancel", "/api/runs/{id}/retry", "/api/runs/{id}/steer", "/api/runs/{id}/permission", "/api/runs/{id}/export", "/doc", diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index 56584f0..3a9ae1b 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -25,6 +25,9 @@ const OpenAPI = `{ "/api/runs/{id}": { "get": {"responses": {"200": {"description": "run detail", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunDetail"}}}}}} }, + "/api/runs/{id}/watch": { + "get": {"responses": {"200": {"description": "server-sent event stream of run deltas", "content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/WatchEvent"}}}}}} + }, "/api/runs/{id}/approve": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/reject": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/cancel": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, @@ -40,6 +43,13 @@ const OpenAPI = `{ "Ok": {"type": "object", "required": ["ok"], "properties": {"ok": {"type": "boolean"}}}, "PlanResponse": {"type": "object", "required": ["graph"], "properties": {"graph": {"$ref": "#/components/schemas/Graph"}}}, "CreateRunResponse": {"type": "object", "required": ["runID"], "properties": {"runID": {"type": "string"}}}, + "CreateRunRequest": { + "type": "object", "required": ["graph"], + "properties": { + "graph": {"$ref": "#/components/schemas/Graph"}, + "autoApproveGates": {"type": "boolean", "description": "approve human gates automatically instead of waiting for an operator"} + } + }, "RunSummary": { "type": "object", "required": ["id", "status"], "properties": { @@ -112,12 +122,30 @@ const OpenAPI = `{ "type": "object", "required": ["runID"], "properties": { "runID": {"type": "string"}, "status": {"type": "string"}, "done": {"type": "boolean"}, + "autoApproveGates": {"type": "boolean"}, "graph": {"$ref": "#/components/schemas/Graph"}, "states": {"type": "object", "additionalProperties": {"type": "string"}}, "attempts": {"type": "object", "additionalProperties": {"type": "array", "items": {"$ref": "#/components/schemas/Attempt"}}}, "events": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}} } }, + "WatchEvent": { + "type": "object", "required": ["seq", "type", "runID"], + "properties": { + "seq": {"type": "integer"}, + "type": {"type": "string", "enum": ["event", "done"]}, + "runID": {"type": "string"}, + "event": {"type": "string"}, + "nodeID": {"type": "string"}, + "from": {"type": "string"}, + "to": {"type": "string"}, + "attemptID": {"type": "string"}, + "gate": {"type": "boolean"}, + "awaitingApproval": {"type": "boolean"}, + "status": {"type": "string"}, + "payload": {"type": "string"} + } + }, "Export": { "type": "object", "required": ["runID", "graph", "events"], "properties": { diff --git a/internal/sched/gates_test.go b/internal/sched/gates_test.go index 8f36f70..f4f8894 100644 --- a/internal/sched/gates_test.go +++ b/internal/sched/gates_test.go @@ -134,6 +134,41 @@ func TestTokenBudgetBoundsRetries(t *testing.T) { } } +func TestAutoApproveGatesSkipsOperator(t *testing.T) { + st := newStore(t) + clk := fakeClock() + drv := sched.NewFakeDriver(clk, scriptsFor("w")) + ver := sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}) + s := newSched(t, st, drv, ver, clk, sched.Options{Concurrency: 1}) + g := &graph.Graph{Nodes: []*graph.Node{ + agent("w"), + {ID: "gate", Type: graph.NodeHuman, Objective: "approve", Priority: graph.PriorityNormal, DependsOn: []graph.NodeID{"w"}}, + }} + h, err := s.CreateWithOptions(context.Background(), "run-autoapprove", g, sched.RunOptions{AutoApproveGates: true}) + if err != nil { + t.Fatal(err) + } + drive(t, h, clk, 100) + if !h.Done() { + t.Fatal("auto-approve run did not settle") + } + // The gate must have passed without an operator action. + if st2, _ := h.State("gate"); st2 != graph.StateDone { + t.Fatalf("gate state = %s, want done (auto-approved)", st2) + } + atts, _ := st.Attempts(context.Background(), "run-autoapprove", "gate") + if len(atts) != 1 || atts[0].Status != "done" { + t.Fatalf("gate attempts = %+v, want a single done attempt", atts) + } + r, err := st.Run(context.Background(), "run-autoapprove") + if err != nil { + t.Fatal(err) + } + if r.Status != "completed" { + t.Fatalf("run status = %s, want completed", r.Status) + } +} + func TestCheckNodeRunsCommandAndRetries(t *testing.T) { st := newStore(t) clk := fakeClock() diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 6fa4e4b..a408efd 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -50,6 +50,13 @@ type Result struct { Budget bool // aborted because the node budget expired } +// RunOptions carries per-run scheduler behavior set at creation time. +type RunOptions struct { + // AutoApproveGates approves human gates immediately instead of + // parking them until an operator approves or rejects. + AutoApproveGates bool +} + type Verdict struct { Pass bool Feedback string @@ -117,6 +124,8 @@ type RunHandle struct { tr *graph.Tracker mu sync.Mutex + autoApproveGates bool + sessions map[graph.NodeID]*sessionRec suspended map[graph.NodeID]*sessionRec // permission-blocked sessions retryAt map[graph.NodeID]time.Time @@ -133,16 +142,22 @@ type RunHandle struct { started time.Time } -// Create starts a new run for g. +// Create starts a new run for g with default options. func (s *Scheduler) Create(ctx context.Context, runID string, g *graph.Graph) (*RunHandle, error) { + return s.CreateWithOptions(ctx, runID, g, RunOptions{}) +} + +// CreateWithOptions starts a new run for g with per-run behavior +// (e.g. auto-approving human gates). +func (s *Scheduler) CreateWithOptions(ctx context.Context, runID string, g *graph.Graph, opts RunOptions) (*RunHandle, error) { if err := graph.Validate(g); err != nil { return nil, err } now := s.clk.Now() - if err := s.store.CreateRun(ctx, runID, g, now); err != nil { + if err := s.store.CreateRunWithOpts(ctx, runID, g, opts.AutoApproveGates, now); err != nil { return nil, err } - return s.newHandle(ctx, runID, g, now) + return s.newHandle(ctx, runID, g, now, opts.AutoApproveGates) } // Load resumes a persisted run: events are replayed into a fresh tracker @@ -211,18 +226,19 @@ func (s *Scheduler) Load(ctx context.Context, runID string) (*RunHandle, error) } } h := &RunHandle{ - s: s, - runID: runID, - g: r.Graph, - tr: tr, - sessions: map[graph.NodeID]*sessionRec{}, - suspended: map[graph.NodeID]*sessionRec{}, - retryAt: map[graph.NodeID]time.Time{}, - age: map[graph.NodeID]int{}, - feedback: map[graph.NodeID]string{}, - results: make(chan Result, resultsBuffer), - holder: fmt.Sprintf("corral-%d", now.UnixNano()), - started: now, + s: s, + runID: runID, + g: r.Graph, + tr: tr, + autoApproveGates: r.AutoApproveGates, + sessions: map[graph.NodeID]*sessionRec{}, + suspended: map[graph.NodeID]*sessionRec{}, + retryAt: map[graph.NodeID]time.Time{}, + age: map[graph.NodeID]int{}, + feedback: map[graph.NodeID]string{}, + results: make(chan Result, resultsBuffer), + holder: fmt.Sprintf("corral-%d", now.UnixNano()), + started: now, } for _, ev := range events { if ev.Type == store.EventRetry { @@ -253,24 +269,25 @@ func retryReadyAt(events []store.Event, nodeID graph.NodeID) (time.Time, bool) { return time.Time{}, false } -func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time) (*RunHandle, error) { +func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time, autoApproveGates bool) (*RunHandle, error) { tr, err := graph.NewTracker(g) if err != nil { return nil, err } return &RunHandle{ - s: s, - runID: runID, - g: g, - tr: tr, - sessions: map[graph.NodeID]*sessionRec{}, - suspended: map[graph.NodeID]*sessionRec{}, - retryAt: map[graph.NodeID]time.Time{}, - age: map[graph.NodeID]int{}, - feedback: map[graph.NodeID]string{}, - results: make(chan Result, resultsBuffer), - holder: fmt.Sprintf("corral-%d", now.UnixNano()), - started: now, + s: s, + runID: runID, + g: g, + tr: tr, + autoApproveGates: autoApproveGates, + sessions: map[graph.NodeID]*sessionRec{}, + suspended: map[graph.NodeID]*sessionRec{}, + retryAt: map[graph.NodeID]time.Time{}, + age: map[graph.NodeID]int{}, + feedback: map[graph.NodeID]string{}, + results: make(chan Result, resultsBuffer), + holder: fmt.Sprintf("corral-%d", now.UnixNano()), + started: now, }, nil } @@ -788,7 +805,8 @@ func (h *RunHandle) startMerge(ctx context.Context, n *graph.Node, attemptID str } // startGate parks a human gate node in running until an operator approves -// or rejects it. No driver session is involved. +// or rejects it. No driver session is involved. When the run was created +// with autoApproveGates the gate is approved immediately instead. func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID string, no int, now time.Time) error { sess := &gateSession{id: "gate:" + string(n.ID)} started := now.UnixMilli() @@ -807,8 +825,27 @@ func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID stri }); err != nil { return err } - return h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, - `{"phase":"start","sessionID":"`+sess.ID()+`"}`) + if err := h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, + `{"phase":"start","sessionID":"`+sess.ID()+`"}`); err != nil { + return err + } + if !h.autoApproveGates { + return nil + } + // Auto-approve: no operator round-trip; the gate passes through the + // evidence machine (running → verifying → done) like a manual approve. + delete(h.sessions, n.ID) + finished := now.UnixMilli() + if err := h.transit(ctx, n.ID, graph.StateRunning, graph.StateVerifying, ""); err != nil { + return err + } + if err := h.transit(ctx, n.ID, graph.StateVerifying, graph.StateDone, ""); err != nil { + return err + } + return h.s.store.RecordAttempt(ctx, store.Attempt{ + ID: attemptID, RunID: h.runID, NodeID: string(n.ID), No: no, + Status: "done", SessionID: sess.ID(), StartedAt: &started, FinishedAt: &finished, + }) } // completeInline registers the session, transitions to running, records diff --git a/internal/store/store.go b/internal/store/store.go index 824ed1f..da8b979 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -58,10 +58,11 @@ type Attempt struct { } type Run struct { - ID string `json:"id"` - Graph *graph.Graph `json:"graph"` - Status string `json:"status"` // active|completed|canceled - CreatedAt int64 `json:"createdAt"` + ID string `json:"id"` + Graph *graph.Graph `json:"graph"` + Status string `json:"status"` // active|completed|canceled + AutoApproveGates bool `json:"autoApproveGates"` + CreatedAt int64 `json:"createdAt"` } // NodeRow is the materialized per-node state. @@ -120,6 +121,7 @@ CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, graph TEXT NOT NULL, status TEXT NOT NULL, + auto_approve_gates INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS events ( @@ -174,11 +176,56 @@ CREATE TABLE IF NOT EXISTS artifacts ( content TEXT NOT NULL DEFAULT '', PRIMARY KEY(run_id, attempt_id, name) );` - _, err := db.Exec(schema) - return err + if _, err := db.Exec(schema); err != nil { + return err + } + // Migrate pre-existing databases: the auto-approve column is added + // when it is missing (CREATE TABLE IF NOT EXISTS does not alter an + // existing table). + ok, err := columnExists(db, "runs", "auto_approve_gates") + if err != nil { + return err + } + if !ok { + if _, err := db.Exec("ALTER TABLE runs ADD COLUMN auto_approve_gates INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + } + return nil +} + +// columnExists reports whether table has column. +func columnExists(db *sql.DB, table, column string) (bool, error) { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid, notnull, pk int + var name, ctype string + var dflt any + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() } func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now time.Time) error { + return s.createRun(ctx, runID, g, false, now) +} + +// CreateRunWithOpts creates a run with scheduler options (auto-approving +// human gates) persisted on the run row. +func (s *Store) CreateRunWithOpts(ctx context.Context, runID string, g *graph.Graph, autoApproveGates bool, now time.Time) error { + return s.createRun(ctx, runID, g, autoApproveGates, now) +} + +func (s *Store) createRun(ctx context.Context, runID string, g *graph.Graph, autoApproveGates bool, now time.Time) error { gj, err := json.Marshal(g) if err != nil { return err @@ -188,9 +235,13 @@ func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now return err } defer tx.Rollback() + autoApprove := 0 + if autoApproveGates { + autoApprove = 1 + } if _, err := tx.ExecContext(ctx, - `INSERT INTO runs(id, graph, status, created_at) VALUES(?, ?, 'active', ?)`, - runID, string(gj), now.UnixMilli()); err != nil { + `INSERT INTO runs(id, graph, status, auto_approve_gates, created_at) VALUES(?, ?, 'active', ?, ?)`, + runID, string(gj), autoApprove, now.UnixMilli()); err != nil { return err } for _, n := range g.Nodes { @@ -209,21 +260,23 @@ func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now } func (s *Store) Run(ctx context.Context, runID string) (*Run, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, graph, status, created_at FROM runs WHERE id = ?`, runID) + row := s.db.QueryRowContext(ctx, `SELECT id, graph, status, auto_approve_gates, created_at FROM runs WHERE id = ?`, runID) var r Run var gj string - if err := row.Scan(&r.ID, &gj, &r.Status, &r.CreatedAt); err != nil { + var autoApprove int + if err := row.Scan(&r.ID, &gj, &r.Status, &autoApprove, &r.CreatedAt); err != nil { return nil, err } if err := json.Unmarshal([]byte(gj), &r.Graph); err != nil { return nil, fmt.Errorf("decode graph: %w", err) } + r.AutoApproveGates = autoApprove != 0 return &r, nil } // ListRuns returns all runs with their status. func (s *Store) ListRuns(ctx context.Context) ([]Run, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, graph, status, created_at FROM runs ORDER BY created_at`) + rows, err := s.db.QueryContext(ctx, `SELECT id, graph, status, auto_approve_gates, created_at FROM runs ORDER BY created_at`) if err != nil { return nil, err } @@ -232,12 +285,14 @@ func (s *Store) ListRuns(ctx context.Context) ([]Run, error) { for rows.Next() { var r Run var gj string - if err := rows.Scan(&r.ID, &gj, &r.Status, &r.CreatedAt); err != nil { + var autoApprove int + if err := rows.Scan(&r.ID, &gj, &r.Status, &autoApprove, &r.CreatedAt); err != nil { return nil, err } if err := json.Unmarshal([]byte(gj), &r.Graph); err != nil { return nil, fmt.Errorf("decode graph: %w", err) } + r.AutoApproveGates = autoApprove != 0 out = append(out, r) } return out, rows.Err() diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5601ee5..deaee67 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "path/filepath" "testing" "time" @@ -135,6 +136,84 @@ func TestAttemptsUniquePerNode(t *testing.T) { } } +// TestMigrateAddsAutoApproveColumn opens a database created with the +// pre-auto-approve schema and verifies the column is added, so existing +// deployments keep working after an upgrade. +func TestMigrateAddsAutoApproveColumn(t *testing.T) { + path := filepath.Join(t.TempDir(), "old.db") + oldDB, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := oldDB.Exec(`CREATE TABLE runs ( + id TEXT PRIMARY KEY, + graph TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL + );`); err != nil { + t.Fatal(err) + } + oldDB.Close() + + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + ctx := context.Background() + if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + t.Fatal(err) + } + ru, err := st.Run(ctx, "r1") + if err != nil { + t.Fatal(err) + } + if ru.AutoApproveGates { + t.Fatal("migrated run should default autoApproveGates to false") + } +} + +func TestAutoApproveGatesPersisted(t *testing.T) { + st := open(t) + ctx := context.Background() + // Default run: flag off. + if err := st.CreateRun(ctx, "off", testGraph(t), now()); err != nil { + t.Fatal(err) + } + // Explicit run: flag on. + if err := st.CreateRunWithOpts(ctx, "on", testGraph(t), true, now()); err != nil { + t.Fatal(err) + } + off, err := st.Run(ctx, "off") + if err != nil { + t.Fatal(err) + } + if off.AutoApproveGates { + t.Fatal("default run has autoApproveGates set") + } + on, err := st.Run(ctx, "on") + if err != nil { + t.Fatal(err) + } + if !on.AutoApproveGates { + t.Fatal("autoApproveGates not persisted on the run") + } + // ListRuns carries the flag too. + runs, err := st.ListRuns(ctx) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 { + t.Fatalf("runs = %d, want 2", len(runs)) + } + for _, r := range runs { + want := r.ID == "on" + if r.AutoApproveGates != want { + t.Fatalf("run %s autoApproveGates = %v, want %v", r.ID, r.AutoApproveGates, want) + } + } +} + func TestMarkInterrupted(t *testing.T) { st := open(t) ctx := context.Background() From 902e92f229a6b005564b7f8e8468f3cdc65881c6 Mon Sep 17 00:00:00 2001 From: corral Date: Mon, 10 Aug 2026 16:46:34 -0300 Subject: [PATCH 04/26] corral: work --- .opencode/tools/corral.ts | 25 ++- example/opencode.json | 3 +- internal/assets/corral.ts | 25 ++- internal/assets/opencode.json | 3 +- internal/daemon/daemon.go | 120 ++++++++++- internal/daemon/e2e_test.go | 328 ++++++++++++++++++++++++++++++ internal/daemon/hardening_test.go | 1 + internal/daemon/openapi.go | 15 ++ internal/ocx/client.go | 23 ++- internal/sched/sched.go | 18 +- internal/store/redact_test.go | 2 +- internal/store/store.go | 46 +++-- internal/store/store_test.go | 8 +- 13 files changed, 587 insertions(+), 30 deletions(-) diff --git a/.opencode/tools/corral.ts b/.opencode/tools/corral.ts index 37551b6..000a661 100644 --- a/.opencode/tools/corral.ts +++ b/.opencode/tools/corral.ts @@ -67,7 +67,10 @@ export const plan = tool({ export const start = tool({ description: "Start a corral run from an approved graph.", - args: { graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)") }, + args: { + graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)"), + autoApproveGates: tool.schema.boolean().optional().describe("When true, the run is pre-authorized: the orchestrator approves human gates itself as they are reached, without waiting for the operator"), + }, async execute(args, context) { let graph: unknown try { @@ -75,7 +78,9 @@ export const start = tool({ } catch { return "error: graph is not valid JSON" } - return call("/api/runs", { graph }, roleFor(context.agent)) + const body: Record = { graph } + if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates + return call("/api/runs", body, roleFor(context.agent)) }, }) @@ -89,6 +94,22 @@ export const status = tool({ }, }) +export const watch = tool({ + description: + "Watch a corral run and block until its state changes (new events, a human gate awaiting approval, or completion) or the timeout elapses. Drive the run loop by calling this repeatedly and passing the previous response's `since` cursor back. `gatesAwaitingApproval` lists human gates parked in running waiting for a decision: if the response's `autoApproveGates` is true the run is pre-authorized and you should approve each gate via corral_approve; otherwise never approve them yourself — report them to the user and keep watching until they resolve.", + args: { + runID: tool.schema.string(), + since: tool.schema.number().optional().describe("Event cursor; only return events after this"), + timeout: tool.schema.number().optional().describe("Block for up to this many seconds (default 60, max 120)"), + }, + async execute(args, context) { + const q = new URLSearchParams() + if (args.since !== undefined) q.set("since", String(args.since)) + if (args.timeout !== undefined) q.set("timeout", String(args.timeout)) + return call(`/api/runs/${args.runID}/watch?${q}`, undefined, roleFor(context.agent)) + }, +}) + export const approve = tool({ description: "Approve a human gate (or the run's merge) by node id.", args: { diff --git a/example/opencode.json b/example/opencode.json index 94e05f6..edbdb6e 100644 --- a/example/opencode.json +++ b/example/opencode.json @@ -4,13 +4,14 @@ "corral-orchestrator": { "description": "Corral orchestrator: controls runs and graphs, never edits files or runs arbitrary bash.", "mode": "primary", - "prompt": "You are the corral orchestrator. You plan runs, start them, monitor status, approve or reject gates, and steer workers. You never edit files and never run bash commands.", + "prompt": "You are the corral orchestrator. You start runs, watch them, approve or reject gates, and steer workers. You never edit files and never run bash commands.\n\nRun loop: start a run from an approved graph with corral_start, then repeatedly call corral_watch with the runID and the previous response's `since` cursor (use a timeout around 60) to follow it. Report milestones to the user as nodes progress.\n\nWhen corral_watch reports gatesAwaitingApproval:\n- If the response's autoApproveGates is true, the run is pre-authorized: call corral_approve for each waiting gate and continue watching.\n- If autoApproveGates is false, you are NOT pre-authorized: never call corral_approve. Tell the user the gate awaits their approval, and keep calling corral_watch until the gate resolves, then continue driving the run.\n\nKeep watching until the response shows done: true, then summarize the outcome for the user.", "permission": { "edit": "deny", "bash": "deny", "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", + "corral_watch": "allow", "corral_approve": "allow", "corral_reject": "allow", "corral_cancel": "allow", diff --git a/internal/assets/corral.ts b/internal/assets/corral.ts index 37551b6..000a661 100644 --- a/internal/assets/corral.ts +++ b/internal/assets/corral.ts @@ -67,7 +67,10 @@ export const plan = tool({ export const start = tool({ description: "Start a corral run from an approved graph.", - args: { graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)") }, + args: { + graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)"), + autoApproveGates: tool.schema.boolean().optional().describe("When true, the run is pre-authorized: the orchestrator approves human gates itself as they are reached, without waiting for the operator"), + }, async execute(args, context) { let graph: unknown try { @@ -75,7 +78,9 @@ export const start = tool({ } catch { return "error: graph is not valid JSON" } - return call("/api/runs", { graph }, roleFor(context.agent)) + const body: Record = { graph } + if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates + return call("/api/runs", body, roleFor(context.agent)) }, }) @@ -89,6 +94,22 @@ export const status = tool({ }, }) +export const watch = tool({ + description: + "Watch a corral run and block until its state changes (new events, a human gate awaiting approval, or completion) or the timeout elapses. Drive the run loop by calling this repeatedly and passing the previous response's `since` cursor back. `gatesAwaitingApproval` lists human gates parked in running waiting for a decision: if the response's `autoApproveGates` is true the run is pre-authorized and you should approve each gate via corral_approve; otherwise never approve them yourself — report them to the user and keep watching until they resolve.", + args: { + runID: tool.schema.string(), + since: tool.schema.number().optional().describe("Event cursor; only return events after this"), + timeout: tool.schema.number().optional().describe("Block for up to this many seconds (default 60, max 120)"), + }, + async execute(args, context) { + const q = new URLSearchParams() + if (args.since !== undefined) q.set("since", String(args.since)) + if (args.timeout !== undefined) q.set("timeout", String(args.timeout)) + return call(`/api/runs/${args.runID}/watch?${q}`, undefined, roleFor(context.agent)) + }, +}) + export const approve = tool({ description: "Approve a human gate (or the run's merge) by node id.", args: { diff --git a/internal/assets/opencode.json b/internal/assets/opencode.json index 94e05f6..edbdb6e 100644 --- a/internal/assets/opencode.json +++ b/internal/assets/opencode.json @@ -4,13 +4,14 @@ "corral-orchestrator": { "description": "Corral orchestrator: controls runs and graphs, never edits files or runs arbitrary bash.", "mode": "primary", - "prompt": "You are the corral orchestrator. You plan runs, start them, monitor status, approve or reject gates, and steer workers. You never edit files and never run bash commands.", + "prompt": "You are the corral orchestrator. You start runs, watch them, approve or reject gates, and steer workers. You never edit files and never run bash commands.\n\nRun loop: start a run from an approved graph with corral_start, then repeatedly call corral_watch with the runID and the previous response's `since` cursor (use a timeout around 60) to follow it. Report milestones to the user as nodes progress.\n\nWhen corral_watch reports gatesAwaitingApproval:\n- If the response's autoApproveGates is true, the run is pre-authorized: call corral_approve for each waiting gate and continue watching.\n- If autoApproveGates is false, you are NOT pre-authorized: never call corral_approve. Tell the user the gate awaits their approval, and keep calling corral_watch until the gate resolves, then continue driving the run.\n\nKeep watching until the response shows done: true, then summarize the outcome for the user.", "permission": { "edit": "deny", "bash": "deny", "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", + "corral_watch": "allow", "corral_approve": "allow", "corral_reject": "allow", "corral_cancel": "allow", diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5dce54e..dbb8d1d 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "sort" + "strconv" "strings" "sync" "time" @@ -106,6 +107,7 @@ func (d *Daemon) Handler() http.Handler { mux.HandleFunc("POST /api/runs", d.role(RoleOrchestrator, RoleOperator)(d.handleCreateRun)) mux.HandleFunc("GET /api/runs", d.handleListRuns) mux.HandleFunc("GET /api/runs/{id}", d.handleGetRun) + mux.HandleFunc("GET /api/runs/{id}/watch", d.handleWatchRun) mux.HandleFunc("POST /api/runs/{id}/approve", d.role(RoleOperator, RoleOrchestrator)(d.handleApprove)) mux.HandleFunc("POST /api/runs/{id}/reject", d.role(RoleOperator, RoleOrchestrator)(d.handleReject)) mux.HandleFunc("POST /api/runs/{id}/cancel", d.role(RoleOperator, RoleOrchestrator)(d.handleCancel)) @@ -184,7 +186,8 @@ func (d *Daemon) handlePlan(w http.ResponseWriter, r *http.Request) { func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { var req struct { - Graph *graph.Graph `json:"graph"` + Graph *graph.Graph `json:"graph"` + AutoApproveGates bool `json:"autoApproveGates"` } if err := readJSON(r, &req); err != nil || req.Graph == nil { http.Error(w, "graph required", http.StatusBadRequest) @@ -192,7 +195,7 @@ func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() runID := "run_" + randID(6) - h, err := d.sched.Create(ctx, runID, req.Graph) + h, err := d.sched.Create(ctx, runID, req.Graph, sched.CreateOptions{AutoApproveGates: req.AutoApproveGates}) if err != nil { http.Error(w, "invalid graph: "+err.Error(), http.StatusUnprocessableEntity) return @@ -274,8 +277,12 @@ func (d *Daemon) handleGetRun(w http.ResponseWriter, r *http.Request) { attempts[string(n.ID)] = atts } resp := map[string]any{ - "runID": id, "status": ru.Status, "graph": ru.Graph, - "events": events, "attempts": attempts, + "runID": id, + "status": ru.Status, + "graph": ru.Graph, + "autoApproveGates": ru.AutoApproveGates, + "events": events, + "attempts": attempts, } if h, ok := d.runs[id]; ok { states := map[string]string{} @@ -290,6 +297,111 @@ func (d *Daemon) handleGetRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// handleWatchRun long-polls a run for the orchestrator run loop. It +// returns as soon as the run produces new events (milestones, a gate +// awaiting approval, resolution, completion) or after the timeout, and +// always carries the current snapshot: node states, gates awaiting +// approval, whether the run is pre-authorized to auto-approve them, and +// the event cursor to pass back as `since`. +func (d *Daemon) handleWatchRun(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + q := r.URL.Query() + since, _ := strconv.ParseInt(q.Get("since"), 10, 64) + timeout := 60 + if v := q.Get("timeout"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + timeout = n + } + } + if timeout < 1 { + timeout = 1 + } + if timeout > 120 { + timeout = 120 + } + deadline := time.Now().Add(time.Duration(timeout) * time.Second) + + ctx := r.Context() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + snap, changed, done, err := d.watchSnapshot(ctx, id, since) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if changed || done || time.Now().After(deadline) { + writeJSON(w, http.StatusOK, snap) + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// watchSnapshot builds the watch response for a run. changed reports +// whether any event happened after since; done whether the run settled. +func (d *Daemon) watchSnapshot(ctx context.Context, id string, since int64) (map[string]any, bool, bool, error) { + ru, err := d.st.Run(ctx, id) + if err != nil { + return nil, false, false, err + } + events, err := d.st.Events(ctx, id) + if err != nil { + return nil, false, false, err + } + maxSeq := since + var newEvents []store.Event + for _, e := range events { + if e.Seq > maxSeq { + maxSeq = e.Seq + } + if e.Seq > since { + newEvents = append(newEvents, e) + } + } + + states := map[string]string{} + done := false + if h, ok := d.runs[id]; ok { + for _, n := range ru.Graph.Nodes { + if st, ok := h.State(n.ID); ok { + states[string(n.ID)] = string(st) + } + } + done = h.Done() + } else { + if ns, err := d.st.NodeStates(ctx, id); err == nil { + for nid, st := range ns { + states[string(nid)] = string(st) + } + } + done = ru.Status != "active" + } + + var gates []string + for _, n := range ru.Graph.Nodes { + if n.Type == graph.NodeHuman && states[string(n.ID)] == string(graph.StateRunning) { + gates = append(gates, string(n.ID)) + } + } + + return map[string]any{ + "runID": id, + "status": ru.Status, + "done": done, + "autoApproveGates": ru.AutoApproveGates, + "states": states, + "gatesAwaitingApproval": gates, + "since": maxSeq, + "events": newEvents, + }, len(newEvents) > 0, done, nil +} + func (d *Daemon) nodeAction(w http.ResponseWriter, r *http.Request, fn func(ctx context.Context, id graph.NodeID) error) { var req struct { NodeID string `json:"nodeID"` diff --git a/internal/daemon/e2e_test.go b/internal/daemon/e2e_test.go index b7f5753..44efa27 100644 --- a/internal/daemon/e2e_test.go +++ b/internal/daemon/e2e_test.go @@ -3,6 +3,8 @@ package daemon_test import ( "context" "encoding/json" + "fmt" + "net/http" "net/http/httptest" "os" "os/exec" @@ -11,6 +13,7 @@ import ( "testing" "time" + "corral/internal/assets" "corral/internal/clock" "corral/internal/daemon" "corral/internal/graph" @@ -168,3 +171,328 @@ func ocNodeE2E(id graph.NodeID, prompt, file, marker string) *graph.Node { Budget: graph.Budget{MaxDuration: 12 * time.Minute}, } } + +// orchGraph builds the small approved graph the orchestrator drives in the +// run-loop E2E: a writing worker, a human gate, and an approval-gated merge. +func orchGraph() *graph.Graph { + return &graph.Graph{Version: 1, Nodes: []*graph.Node{ + ocNodeE2E("w1", "Create a file named alpha.txt containing one line: CORRAL. Do not run any other commands.", "alpha.txt", ""), + {ID: "gate", Type: graph.NodeHuman, Objective: "approve", Priority: graph.PriorityNormal, DependsOn: []graph.NodeID{"w1"}}, + {ID: "m", Type: graph.NodeMerge, Objective: "merge accepted work", Priority: graph.PriorityNormal, + DependsOn: []graph.NodeID{"gate"}, + Verification: &graph.Verification{Kind: "command", Command: []string{"test", "-s", "alpha.txt"}}}, + }} +} + +// orchEnv is the full real-OpenCode stack for the orchestrator run-loop +// test: a git project with the corral plugin + agents installed, an +// embedded opencode serve, the daemon, and its HTTP API. +type orchEnv struct { + api *api + oc *ocx.Client + proj string + srv *spike.Server +} + +func setupOrchestratorEnv(t *testing.T, ctx context.Context) *orchEnv { + t.Helper() + proj, err := os.MkdirTemp("", "corral-orch-e2e-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(proj) }) + for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"commit", "-q", "--allow-empty", "-m", "init"}} { + cmd := exec.Command("git", args...) + cmd.Dir = proj + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + // Install the corral plugin + agent config so the real OpenCode server + // exposes the corral_* tools and the corral-orchestrator agent. + if err := os.MkdirAll(filepath.Join(proj, ".opencode", "tools"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, ".opencode", "tools", "corral.ts"), []byte(assets.CorralPluginTS), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, "opencode.json"), []byte(assets.OpenCodeConfigJSON), 0o644); err != nil { + t.Fatal(err) + } + + srv, err := spike.StartServer(ctx, proj, 0, os.Stderr) + if err != nil { + t.Fatal(err) + } + t.Cleanup(srv.Stop) + + st, err := store.Open(filepath.Join(t.TempDir(), "e2e.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + oc := ocx.New(srv.Base, proj) + drv := ocxadapter.New(oc, ocxadapter.Options{PollInterval: 400 * time.Millisecond}) + t.Cleanup(func() { drv.Close() }) + wtm := worktree.NewManager(proj) + s := sched.New(st, drv, &sched.EngineVerifier{Eng: verify.New(proj)}, clock.Real{}, sched.Options{ + Concurrency: 2, Worktrees: wtm, + }) + d := daemon.New(st, s, nil, proj, "") + srvHTTP := httptest.NewServer(d.Handler()) + t.Cleanup(srvHTTP.Close) + t.Setenv("CORRAL_DAEMON_URL", srvHTTP.URL) + + return &orchEnv{api: &api{t: t, cli: srvHTTP.Client(), base: srvHTTP.URL}, oc: oc, proj: proj, srv: srv} +} + +// orchTools is the tool allowlist for the orchestrator session: only the +// corral_* control tools, so the agent can never wander into edits/bash. +var orchTools = map[string]bool{ + "corral_plan": true, "corral_start": true, "corral_status": true, + "corral_watch": true, "corral_approve": true, "corral_reject": true, + "corral_cancel": true, "corral_retry": true, "corral_steer": true, +} + +func orchestratorPrompt(autoApprove bool) string { + gj, _ := json.Marshal(orchGraph()) + return fmt.Sprintf(`You are driving a corral run as the orchestrator. + +First call corral_start with the EXACT graph JSON below and autoApproveGates set to %t to create the run: + +%s + +Then drive it to completion: +1. Call corral_watch with the runID from corral_start, passing a timeout around 60 and the previous response's "since" value on later calls. +2. Each time corral_watch returns, report the current progress to the user (nodes done or running, and any milestones). +3. When gatesAwaitingApproval is non-empty and autoApproveGates is true, you are pre-authorized: call corral_approve with the runID and each waiting gate nodeID, then keep watching. +4. When gatesAwaitingApproval is non-empty and autoApproveGates is false, you are NOT pre-authorized: never call corral_approve. Report to the user that the gate awaits their approval, and keep calling corral_watch until it resolves, then continue. +5. Keep watching until the response shows done: true, then report the final outcome. + +You are the corral-orchestrator agent: never edit files, never run bash, and use only the corral_* tools.`, autoApprove, gj) +} + +// waitForRun polls the daemon API until at least one run exists and +// returns its id (the orchestrator creates it via corral_start). +func waitForRun(t *testing.T, env *orchEnv, timeout time.Duration) string { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body := env.api.do("operator", http.MethodGet, "/api/runs", nil) + if code == http.StatusOK { + var runs []struct { + ID string `json:"id"` + } + if json.Unmarshal([]byte(body), &runs) == nil && len(runs) > 0 { + return runs[0].ID + } + } + time.Sleep(500 * time.Millisecond) + } + t.Fatal("no run appeared; did the orchestrator call corral_start?") + return "" +} + +// runDetail returns the daemon's run detail as raw JSON. +func (env *orchEnv) runDetail(runID string) (int, string) { + return env.api.do("operator", http.MethodGet, "/api/runs/"+runID, nil) +} + +func (env *orchEnv) nodeState(runID, nodeID string) string { + code, body := env.runDetail(runID) + if code != http.StatusOK { + return "" + } + var r struct { + States map[string]string `json:"states"` + Done bool `json:"done"` + } + if json.Unmarshal([]byte(body), &r) != nil { + return "" + } + return r.States[nodeID] +} + +func (env *orchEnv) waitNodeState(runID, nodeID, want string, timeout time.Duration) { + t := env.api.t + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if env.nodeState(runID, nodeID) == want { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("node %s never reached %s", nodeID, want) +} + +func (env *orchEnv) waitRunDone(runID string, timeout time.Duration) { + t := env.api.t + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body := env.runDetail(runID) + if code == http.StatusOK { + var r struct { + Done bool `json:"done"` + } + if json.Unmarshal([]byte(body), &r) == nil && r.Done { + return + } + } + time.Sleep(300 * time.Millisecond) + } + code, body := env.runDetail(runID) + t.Fatalf("run %s never completed (last: %d %s)", runID, code, body) +} + +// sessionTools returns the ordered completed tool calls of a session and +// the assistant's text output, used to prove the orchestrator drove the +// loop (start/watch/approve) the way the policy dictates. +func sessionTools(t *testing.T, oc *ocx.Client, sid string) (tools []string, text string) { + t.Helper() + msgs, err := oc.Messages(context.Background(), sid, 0) + if err != nil { + return nil, "" + } + var out strings.Builder + for _, m := range msgs { + if m.Info.Role != "assistant" { + continue + } + for _, p := range m.Parts { + var part struct { + Type string `json:"type"` + Text string `json:"text"` + Tool string `json:"tool"` + State string `json:"state"` + } + if json.Unmarshal(p, &part) != nil { + continue + } + switch part.Type { + case "text": + out.WriteString(part.Text) + out.WriteString("\n") + case "tool": + if part.State == "completed" { + tools = append(tools, part.Tool) + } + } + } + } + return tools, out.String() +} + +// TestOrchestratorRunLoopRealOpenCode exercises the corral-orchestrator +// agent prompt end to end against a real OpenCode install. The +// orchestrator session starts a run with corral_start and drives the watch +// loop, and the two subtests cover the approval policy: +// - pre-authorized (autoApproveGates=true): the orchestrator approves the +// gate itself and the run completes with no human action; +// - not pre-authorized: the run parks at the gate, the orchestrator +// reports it and never approves, and the run resumes only after an +// external (operator) API approve. +func TestOrchestratorRunLoopRealOpenCode(t *testing.T) { + livetest.SkipIfDisabled(t) + if _, err := exec.LookPath("opencode"); err != nil { + t.Skip("opencode binary not found") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + for _, tc := range []struct { + name string + autoApprove bool + }{ + {"pre-authorized: run completes without any human", true}, + {"not pre-authorized: parks at the gate, resumes after external approve", false}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + env := setupOrchestratorEnv(t, ctx) + sess, err := env.oc.CreateSession(ctx, "corral/orchestrator") + if err != nil { + t.Fatalf("create orchestrator session: %v", err) + } + if err := env.oc.PromptAsyncAgentWithTools(ctx, sess.ID, orchestratorPrompt(tc.autoApprove), "", "corral-orchestrator", orchTools); err != nil { + t.Fatalf("prompt orchestrator: %v", err) + } + + runID := waitForRun(t, env, 5*time.Minute) + + if tc.autoApprove { + // The orchestrator must approve the gate itself: the run + // reaches the gate and completes with no operator action. + env.waitRunDone(runID, 12*time.Minute) + // Gate auto-approved and the merge folded the file into main. + if got := env.nodeState(runID, "gate"); got != string(graph.StateDone) { + t.Fatalf("gate state = %s, want done (orchestrator should have approved it)", got) + } + } else { + // The run parks at the gate: the orchestrator must NOT + // approve on its own. + env.waitNodeState(runID, "gate", string(graph.StateRunning), 8*time.Minute) + // Give the orchestrator a generous window to (incorrectly) + // approve; a policy violation would resolve the gate here. + time.Sleep(8 * time.Second) + if got := env.nodeState(runID, "gate"); got != string(graph.StateRunning) { + t.Fatalf("gate state = %s while not pre-authorized, want running (orchestrator must not approve)", got) + } + // The human approves externally, as an operator would. + code, body := env.api.do("operator", http.MethodPost, "/api/runs/"+runID+"/approve", map[string]any{"nodeID": "gate"}) + if code != 200 { + t.Fatalf("external approve: %d %s", code, body) + } + env.waitRunDone(runID, 12*time.Minute) + } + + // The merged artifact must be in the main checkout. + data, err := os.ReadFile(filepath.Join(env.proj, "alpha.txt")) + if err != nil || len(strings.TrimSpace(string(data))) == 0 { + t.Fatalf("merged content wrong: %v %q", err, data) + } + + // Wait for the orchestrator session to settle, then inspect its + // tool usage: it must have started the run, watched repeatedly, + // and honored the approval policy. + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + if msgs, err := env.oc.Messages(ctx, sess.ID, 4); err == nil && len(msgs) > 0 { + last := msgs[len(msgs)-1] + if last.Info.Finish != nil || last.Info.Error != nil { + break + } + } + time.Sleep(1 * time.Second) + } + tools, transcript := sessionTools(t, env.oc, sess.ID) + + started, watched := false, 0 + approved := false + for _, name := range tools { + switch name { + case "corral_start": + started = true + case "corral_watch": + watched++ + case "corral_approve": + approved = true + } + } + if !started || watched < 2 { + t.Errorf("orchestrator loop wrong: start=%v watch=%d; transcript:\n%s", started, watched, transcript) + } + if tc.autoApprove && !approved { + t.Errorf("pre-authorized orchestrator never called corral_approve; transcript:\n%s", transcript) + } + if !tc.autoApprove && approved { + t.Errorf("not-pre-authorized orchestrator called corral_approve; transcript:\n%s", transcript) + } + if !tc.autoApprove && !strings.Contains(strings.ToLower(transcript), "approval") && !strings.Contains(strings.ToLower(transcript), "gate") { + t.Errorf("orchestrator did not report the gate to the user; transcript:\n%s", transcript) + } + }) + } +} diff --git a/internal/daemon/hardening_test.go b/internal/daemon/hardening_test.go index 437b884..58b89f8 100644 --- a/internal/daemon/hardening_test.go +++ b/internal/daemon/hardening_test.go @@ -82,6 +82,7 @@ func TestOpenAPIContract(t *testing.T) { paths, _ := doc["paths"].(map[string]any) registered := []string{ "/api/health", "/api/plan", "/api/runs", "/api/runs/{id}", + "/api/runs/{id}/watch", "/api/runs/{id}/approve", "/api/runs/{id}/reject", "/api/runs/{id}/cancel", "/api/runs/{id}/retry", "/api/runs/{id}/steer", "/api/runs/{id}/permission", "/api/runs/{id}/export", "/doc", diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index 56584f0..68046c4 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -25,6 +25,9 @@ const OpenAPI = `{ "/api/runs/{id}": { "get": {"responses": {"200": {"description": "run detail", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunDetail"}}}}}} }, + "/api/runs/{id}/watch": { + "get": {"parameters": [{"name": "since", "in": "query", "schema": {"type": "integer"}}, {"name": "timeout", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "run snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WatchResponse"}}}}}} + }, "/api/runs/{id}/approve": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/reject": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/cancel": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, @@ -113,11 +116,23 @@ const OpenAPI = `{ "properties": { "runID": {"type": "string"}, "status": {"type": "string"}, "done": {"type": "boolean"}, "graph": {"$ref": "#/components/schemas/Graph"}, + "autoApproveGates": {"type": "boolean"}, "states": {"type": "object", "additionalProperties": {"type": "string"}}, "attempts": {"type": "object", "additionalProperties": {"type": "array", "items": {"$ref": "#/components/schemas/Attempt"}}}, "events": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}} } }, + "WatchResponse": { + "type": "object", "required": ["runID"], + "properties": { + "runID": {"type": "string"}, "status": {"type": "string"}, "done": {"type": "boolean"}, + "autoApproveGates": {"type": "boolean"}, + "states": {"type": "object", "additionalProperties": {"type": "string"}}, + "gatesAwaitingApproval": {"type": "array", "items": {"type": "string"}}, + "since": {"type": "integer"}, + "events": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}} + } + }, "Export": { "type": "object", "required": ["runID", "graph", "events"], "properties": { diff --git a/internal/ocx/client.go b/internal/ocx/client.go index c620af6..b251b11 100644 --- a/internal/ocx/client.go +++ b/internal/ocx/client.go @@ -73,7 +73,7 @@ func (c *Client) CreateSession(ctx context.Context, title string) (Session, erro } func (c *Client) PromptAsync(ctx context.Context, sid, text, model string) error { - return c.PromptAsyncWithTools(ctx, sid, text, model, nil) + return c.promptAsync(ctx, sid, text, model, "", nil) } // PromptAsyncWithTools sends an async prompt with an optional tool @@ -81,12 +81,33 @@ func (c *Client) PromptAsync(ctx context.Context, sid, text, model string) error // session so the agent cannot call them (e.g. planner sessions get no // bash/edit). func (c *Client) PromptAsyncWithTools(ctx context.Context, sid, text, model string, tools map[string]bool) error { + return c.promptAsync(ctx, sid, text, model, "", tools) +} + +// PromptAsyncAgent sends an async prompt running under a named agent +// (e.g. "corral-orchestrator"), so the session inherits that agent's +// system prompt and tool permissions. +func (c *Client) PromptAsyncAgent(ctx context.Context, sid, text, model, agent string) error { + return c.promptAsync(ctx, sid, text, model, agent, nil) +} + +// PromptAsyncAgentWithTools is PromptAsyncAgent plus a tool allowlist. +func (c *Client) PromptAsyncAgentWithTools(ctx context.Context, sid, text, model, agent string, tools map[string]bool) error { + return c.promptAsync(ctx, sid, text, model, agent, tools) +} + +// promptAsync is the shared prompt_async caller; agent and tools are +// optional session overrides. +func (c *Client) promptAsync(ctx context.Context, sid, text, model, agent string, tools map[string]bool) error { body := map[string]any{ "parts": []map[string]string{{"type": "text", "text": text}}, } if model != "" { body["model"] = model } + if agent != "" { + body["agent"] = agent + } if tools != nil { body["tools"] = tools } diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 6fa4e4b..87fa459 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -133,13 +133,27 @@ type RunHandle struct { started time.Time } +// CreateOptions carries per-run creation policies (extended without +// breaking existing callers, which keep compiling with no options). +type CreateOptions struct { + // AutoApproveGates marks the run as pre-authorized: the orchestrator + // agent approves human gates itself as they are reached, without + // waiting for the operator. When false the orchestrator must never + // approve gates on its own. + AutoApproveGates bool +} + // Create starts a new run for g. -func (s *Scheduler) Create(ctx context.Context, runID string, g *graph.Graph) (*RunHandle, error) { +func (s *Scheduler) Create(ctx context.Context, runID string, g *graph.Graph, opts ...CreateOptions) (*RunHandle, error) { if err := graph.Validate(g); err != nil { return nil, err } + var o CreateOptions + if len(opts) > 0 { + o = opts[0] + } now := s.clk.Now() - if err := s.store.CreateRun(ctx, runID, g, now); err != nil { + if err := s.store.CreateRun(ctx, runID, g, o.AutoApproveGates, now); err != nil { return nil, err } return s.newHandle(ctx, runID, g, now) diff --git a/internal/store/redact_test.go b/internal/store/redact_test.go index 3984e02..816adf8 100644 --- a/internal/store/redact_test.go +++ b/internal/store/redact_test.go @@ -39,7 +39,7 @@ func TestSecretsNeverPersisted(t *testing.T) { g := &graph.Graph{Nodes: []*graph.Node{{ ID: "w1", Type: graph.NodeAgent, Objective: "o", AcceptanceCriteria: []string{"c"}, }}} - if err := st.CreateRun(ctx, "r1", g, time.Now()); err != nil { + if err := st.CreateRun(ctx, "r1", g, false, time.Now()); err != nil { t.Fatal(err) } secret := "Bearer sk-verysecretkey1234567890abcdef" diff --git a/internal/store/store.go b/internal/store/store.go index 824ed1f..3167f71 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "regexp" + "strings" "time" _ "modernc.org/sqlite" @@ -58,10 +59,11 @@ type Attempt struct { } type Run struct { - ID string `json:"id"` - Graph *graph.Graph `json:"graph"` - Status string `json:"status"` // active|completed|canceled - CreatedAt int64 `json:"createdAt"` + ID string `json:"id"` + Graph *graph.Graph `json:"graph"` + Status string `json:"status"` // active|completed|canceled + AutoApproveGates bool `json:"autoApproveGates"` + CreatedAt int64 `json:"createdAt"` } // NodeRow is the materialized per-node state. @@ -120,6 +122,7 @@ CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, graph TEXT NOT NULL, status TEXT NOT NULL, + auto_approve_gates INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS events ( @@ -175,10 +178,18 @@ CREATE TABLE IF NOT EXISTS artifacts ( PRIMARY KEY(run_id, attempt_id, name) );` _, err := db.Exec(schema) - return err + if err != nil { + return err + } + // Migrate databases created before the auto-approve-gates column. + if _, err := db.Exec(`ALTER TABLE runs ADD COLUMN auto_approve_gates INTEGER NOT NULL DEFAULT 0`); err != nil && + !strings.Contains(err.Error(), "duplicate column") { + return err + } + return nil } -func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now time.Time) error { +func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, autoApproveGates bool, now time.Time) error { gj, err := json.Marshal(g) if err != nil { return err @@ -189,8 +200,8 @@ func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now } defer tx.Rollback() if _, err := tx.ExecContext(ctx, - `INSERT INTO runs(id, graph, status, created_at) VALUES(?, ?, 'active', ?)`, - runID, string(gj), now.UnixMilli()); err != nil { + `INSERT INTO runs(id, graph, status, auto_approve_gates, created_at) VALUES(?, ?, 'active', ?, ?)`, + runID, string(gj), boolInt(autoApproveGates), now.UnixMilli()); err != nil { return err } for _, n := range g.Nodes { @@ -209,12 +220,14 @@ func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, now } func (s *Store) Run(ctx context.Context, runID string) (*Run, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, graph, status, created_at FROM runs WHERE id = ?`, runID) + row := s.db.QueryRowContext(ctx, `SELECT id, graph, status, auto_approve_gates, created_at FROM runs WHERE id = ?`, runID) var r Run var gj string - if err := row.Scan(&r.ID, &gj, &r.Status, &r.CreatedAt); err != nil { + var auto int + if err := row.Scan(&r.ID, &gj, &r.Status, &auto, &r.CreatedAt); err != nil { return nil, err } + r.AutoApproveGates = auto != 0 if err := json.Unmarshal([]byte(gj), &r.Graph); err != nil { return nil, fmt.Errorf("decode graph: %w", err) } @@ -223,7 +236,7 @@ func (s *Store) Run(ctx context.Context, runID string) (*Run, error) { // ListRuns returns all runs with their status. func (s *Store) ListRuns(ctx context.Context) ([]Run, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, graph, status, created_at FROM runs ORDER BY created_at`) + rows, err := s.db.QueryContext(ctx, `SELECT id, graph, status, auto_approve_gates, created_at FROM runs ORDER BY created_at`) if err != nil { return nil, err } @@ -232,9 +245,11 @@ func (s *Store) ListRuns(ctx context.Context) ([]Run, error) { for rows.Next() { var r Run var gj string - if err := rows.Scan(&r.ID, &gj, &r.Status, &r.CreatedAt); err != nil { + var auto int + if err := rows.Scan(&r.ID, &gj, &r.Status, &auto, &r.CreatedAt); err != nil { return nil, err } + r.AutoApproveGates = auto != 0 if err := json.Unmarshal([]byte(gj), &r.Graph); err != nil { return nil, fmt.Errorf("decode graph: %w", err) } @@ -243,6 +258,13 @@ func (s *Store) ListRuns(ctx context.Context) ([]Run, error) { return out, rows.Err() } +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + func (s *Store) CompleteRun(ctx context.Context, runID string, status string, now time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5601ee5..c0ef695 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -32,7 +32,7 @@ func now() time.Time { return time.UnixMilli(1786000000000) } func TestCreateRunAndReplay(t *testing.T) { st := open(t) ctx := context.Background() - if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } if _, err := st.AppendTransition(ctx, "r1", "a", graph.StatePending, graph.StateReady, "", now()); err != nil { @@ -69,7 +69,7 @@ func TestCreateRunAndReplay(t *testing.T) { func TestLeaseAtomicity(t *testing.T) { st := open(t) ctx := context.Background() - if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } ok, err := st.AcquireLease(ctx, "r1", "a", "h1", time.Minute, now()) @@ -112,7 +112,7 @@ func TestLeaseAtomicity(t *testing.T) { func TestAttemptsUniquePerNode(t *testing.T) { st := open(t) ctx := context.Background() - if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } a := Attempt{ID: "a/1", RunID: "r1", NodeID: "a", No: 1, Status: "running"} @@ -138,7 +138,7 @@ func TestAttemptsUniquePerNode(t *testing.T) { func TestMarkInterrupted(t *testing.T) { st := open(t) ctx := context.Background() - if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } for _, id := range []string{"a/1", "b/1"} { From c773d3f6ca224260d6f7ab7e89aba56d572f1f14 Mon Sep 17 00:00:00 2001 From: corral Date: Tue, 11 Aug 2026 11:39:17 -0300 Subject: [PATCH 05/26] corral: work --- internal/tui/api.go | 20 ++++++---- internal/tui/client_test.go | 80 +++++++++++++++++++++++++++++++++++++ internal/tui/model.go | 48 ++++++++++++++++++++++ internal/tui/tui_test.go | 45 +++++++++++++++++++++ internal/tui/view.go | 15 +++++-- 5 files changed, 198 insertions(+), 10 deletions(-) diff --git a/internal/tui/api.go b/internal/tui/api.go index 632f5e7..e474747 100644 --- a/internal/tui/api.go +++ b/internal/tui/api.go @@ -58,13 +58,14 @@ type AttemptView struct { } type EventView struct { - Seq int64 `json:"seq"` - NodeID string `json:"nodeID,omitempty"` - Type string `json:"type"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - AttemptID string `json:"attemptID,omitempty"` - CreatedAt int64 `json:"createdAt"` + Seq int64 `json:"seq"` + NodeID string `json:"nodeID,omitempty"` + Type string `json:"type"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + AttemptID string `json:"attemptID,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + CreatedAt int64 `json:"createdAt"` } type RunDetail struct { @@ -86,6 +87,7 @@ type API interface { Cancel(ctx context.Context, runID, nodeID string) error Retry(ctx context.Context, runID, nodeID string) error Steer(ctx context.Context, runID, nodeID, message string) error + RespondPermission(ctx context.Context, runID, nodeID, permissionID string, allow bool) error } // Client talks to a corral daemon over HTTP. @@ -169,6 +171,10 @@ func (c *Client) Retry(ctx context.Context, runID, nodeID string) error { func (c *Client) Steer(ctx context.Context, runID, nodeID, message string) error { return c.do(ctx, http.MethodPost, "/api/runs/"+runID+"/steer", map[string]string{"nodeID": nodeID, "message": message}, nil) } +func (c *Client) RespondPermission(ctx context.Context, runID, nodeID, permissionID string, allow bool) error { + return c.do(ctx, http.MethodPost, "/api/runs/"+runID+"/permission", + map[string]any{"nodeID": nodeID, "permissionID": permissionID, "allow": allow}, nil) +} func truncate(s string, n int) string { if len(s) <= n { diff --git a/internal/tui/client_test.go b/internal/tui/client_test.go index 5ecbec8..6b4c78e 100644 --- a/internal/tui/client_test.go +++ b/internal/tui/client_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "encoding/json" "net/http/httptest" "path/filepath" "strings" @@ -155,3 +156,82 @@ func TestClientAgainstDaemon(t *testing.T) { t.Fatalf("events missing") } } + +// TestClientRespondPermission drives a permission-blocked node through the +// daemon: the client's RespondPermission allows it and the node completes. +func TestClientRespondPermission(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + workdir := t.TempDir() + drv := sched.NewFakeDriver(clock.Real{}, nil) + eng := verify.New(workdir) + s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{Concurrency: 2}) + d := daemon.New(st, s, nil, t.TempDir(), "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + d.SetContext(ctx) + srv := httptest.NewServer(d.Handler()) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "") + client.Role = "operator" + + g := &graph.Graph{Nodes: []*graph.Node{{ + ID: "w1", Type: graph.NodeAgent, Role: "worker", + Objective: "write a.txt", AcceptanceCriteria: []string{"a.txt"}, + Priority: graph.PriorityNormal, WriteScope: []string{"a.txt"}, + Verification: &graph.Verification{Kind: "command", Command: []string{"test", "-f", "a.txt"}}, + Meta: map[string]string{"cwd": workdir}, + }}} + drv.SetScript("w1", sched.Script{Delay: 5 * time.Second, Permission: "perm-9", Write: map[string]string{"a.txt": "A"}}) + var created struct{ RunID string } + if err := client.do(ctx, "POST", "/api/runs", map[string]any{"graph": g}, &created); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + dd, err := client.GetRun(ctx, created.RunID) + if err == nil && dd.States["w1"] == "blocked" { + break + } + time.Sleep(100 * time.Millisecond) + } + + // The pending permission is visible in the detail payload. + dd, err := client.GetRun(ctx, created.RunID) + if err != nil { + t.Fatal(err) + } + pid := "" + for _, ev := range dd.Events { + if ev.NodeID == "w1" && ev.To == "blocked" { + var p struct { + Reason string `json:"reason"` + PermissionID string `json:"permissionID"` + } + if json.Unmarshal(ev.Payload, &p) == nil { + pid = p.PermissionID + } + } + } + if pid != "perm-9" { + t.Fatalf("blocked payload permissionID = %q, want perm-9", pid) + } + + if err := client.RespondPermission(ctx, created.RunID, "w1", "perm-9", true); err != nil { + t.Fatalf("respond permission: %v", err) + } + for time.Now().Before(deadline) { + dd, _ := client.GetRun(ctx, created.RunID) + if dd != nil && dd.States["w1"] == "done" { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("node never done after permission allowed") +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 323fc4c..0dc36fa 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,6 +2,7 @@ package tui import ( "context" + "encoding/json" "fmt" "time" @@ -215,6 +216,10 @@ func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.nodeAction("canceled", m.api.Cancel) case "t": return m, m.nodeAction("retried", m.api.Retry) + case "p": + return m, m.permissionAction("allowed", true) + case "d": + return m, m.permissionAction("denied", false) case "s": if id, ok := m.nodeAt(m.nodeCursor); ok { m.steerNode = id @@ -249,6 +254,49 @@ func (m *Model) nodeAction(label string, fn func(context.Context, string, string }) } +// pendingPermission returns the permission id the node is currently blocked +// on, if its latest blocked transition carried a permission request. +func (m *Model) pendingPermission(nodeID string) (string, bool) { + if m.detail == nil { + return "", false + } + pid := "" + for _, ev := range m.detail.Events { + if ev.NodeID != nodeID || ev.To != "blocked" || len(ev.Payload) == 0 { + continue + } + var p struct { + Reason string `json:"reason"` + PermissionID string `json:"permissionID"` + } + if json.Unmarshal(ev.Payload, &p) == nil && p.Reason == "permission" && p.PermissionID != "" { + pid = p.PermissionID + } + } + if pid == "" { + return "", false + } + return pid, true +} + +// permissionAction answers the pending permission of the node under the +// cursor; it is a no-op unless the node is blocked on a permission. +func (m *Model) permissionAction(label string, allow bool) tea.Cmd { + id, ok := m.nodeAt(m.nodeCursor) + if !ok { + return nil + } + pid, ok := m.pendingPermission(id) + if !ok { + return nil + } + runID := m.selectedID + m.status = fmt.Sprintf("%s permission %s/%s", label, runID, id) + return actionCmd(m, label, func(ctx context.Context) error { + return m.api.RespondPermission(ctx, runID, id, pid, allow) + }) +} + func (m *Model) back() { switch m.mode { case modeInspect: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index a8cb9b0..5a3de8e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "encoding/json" "fmt" "strings" "testing" @@ -44,6 +45,10 @@ func (f *fakeAPI) Steer(ctx context.Context, r, n, m string) error { f.act("steer:" + n + ":" + m) return nil } +func (f *fakeAPI) RespondPermission(ctx context.Context, r, n, pid string, allow bool) error { + f.act(fmt.Sprintf("perm:%s:%s:%v", pid, n, allow)) + return nil +} func sampleDetail() *RunDetail { return &RunDetail{ @@ -176,6 +181,46 @@ func TestNodeActions(t *testing.T) { } } +func TestPermissionRespond(t *testing.T) { + d := sampleDetail() + // w1 is blocked on a permission request carried by its transition. + d.States["w1"] = "blocked" + d.Events = append(d.Events, EventView{ + Seq: 2, NodeID: "w1", Type: "transition", From: "running", To: "blocked", + Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-9"}`), + }) + api := &fakeAPI{} + m := New(api, context.Background()) + m.runs = []RunSummary{{ID: "run_1"}} + m.selectedID = "run_1" + m.detail = d + m.mode = modeDetail + m.nodeCursor = 0 // w1 + + // The pending permission is surfaced in the detail view. + view := m.View() + for _, want := range []string{"perm:perm-9", "p allow perm", "d deny perm"} { + if !strings.Contains(view, want) { + t.Fatalf("detail view missing %q:\n%s", want, view) + } + } + + send(t, m, key("p")) + send(t, m, key("d")) + want := []string{"perm:perm-9:w1:true", "perm:perm-9:w1:false"} + if fmt.Sprint(api.actions) != fmt.Sprint(want) { + t.Fatalf("actions = %v, want %v", api.actions, want) + } + + // 'p'/'d' are no-ops on a node with no pending permission (the gate). + m.nodeCursor = 1 + send(t, m, key("p")) + send(t, m, key("d")) + if len(api.actions) != 2 { + t.Fatalf("permission keys acted on non-permission node: %v", api.actions) + } +} + func TestEmptyState(t *testing.T) { m := New(&fakeAPI{}, context.Background()) m.Update(fetchMsg{}) diff --git a/internal/tui/view.go b/internal/tui/view.go index 848be36..299d809 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -111,7 +111,7 @@ func (m *Model) viewDetail() string { } } } - b.WriteString("\n" + m.footer("↑/↓ node · a approve · r reject · c cancel · t retry · s steer · i inspect · esc back · q quit")) + b.WriteString("\n" + m.footer("↑/↓ node · a approve · r reject · c cancel · t retry · s steer · p allow perm · d deny perm · i inspect · esc back · q quit")) return b.String() } @@ -126,7 +126,13 @@ func (m *Model) nodeLine(n GraphNode, deps int) string { if deps > 0 { depsS = styleDim.Render(fmt.Sprintf("(%d deps)", deps)) } - return fmt.Sprintf("%-12s %s %-7s %s %s %s", n.ID, st, typ, prio, attempts, depsS) + permS := "" + if state == "blocked" { + if pid, ok := m.pendingPermission(n.ID); ok { + permS = styleTitle.Render(fmt.Sprintf("perm:%s", pid)) + } + } + return fmt.Sprintf("%-12s %s %-7s %s %s %s %s", n.ID, st, typ, prio, attempts, depsS, permS) } func (m *Model) viewInspect() string { @@ -150,6 +156,9 @@ func (m *Model) viewInspect() string { if n.Verification != nil { b.WriteString(styleDim.Render("verification: ") + n.Verification.Kind + " " + strings.Join(n.Verification.Command, " ") + "\n") } + if pid, ok := m.pendingPermission(n.ID); ok { + b.WriteString(styleDim.Render("permission: ") + styleTitle.Render(pid) + styleMuted.Render(" pending — p allow · d deny") + "\n") + } b.WriteString("\n" + styleDim.Render("attempts") + "\n") for _, at := range m.detail.Attempts[n.ID] { b.WriteString(fmt.Sprintf(" #%d %-10s", at.No, stateColor(at.Status).Render(at.Status))) @@ -170,7 +179,7 @@ func (m *Model) viewInspect() string { b.WriteString(styleMuted.Render(" evidence: "+shortLine(at.Evidence, 90)) + "\n") } } - b.WriteString("\n" + m.footer("esc back · ↑/↓ navigate · a/r/c/t/s act")) + b.WriteString("\n" + m.footer("esc back · ↑/↓ navigate · a/r/c/t/s act · p/d respond perm")) return b.String() } From 06ef37bb0c2dfde59e3da47d2d8d4b54727716b1 Mon Sep 17 00:00:00 2001 From: corral Date: Tue, 11 Aug 2026 11:39:17 -0300 Subject: [PATCH 06/26] corral: work --- README.md | 8 +------ cmd/corral/main.go | 8 +++++-- cmd/corral/main_test.go | 50 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 23a0537..d5422fa 100644 --- a/README.md +++ b/README.md @@ -169,13 +169,7 @@ the seam for future executors. | `corral update` | Install a newer GitHub release after a sanity check | | `corral export ` | Print the full audit export | -`status`, `tui`, and `doctor` read the repository key automatically. Until the -export command does the same, use: - -```sh -CORRAL_DAEMON_KEY="$(cat .corral/api.key)" \ - corral export > audit.json -``` +`status`, `tui`, `doctor`, and `export` read the repository key automatically. ## Development diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 255cf04..bdf8f48 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -519,7 +519,11 @@ func doctorWithURL(wantDir, url string) error { } func exportCmd(runID, outFile string) error { - client := tui.NewClient(daemonURL(), os.Getenv("CORRAL_DAEMON_KEY")) + key, err := readKey(dirOf("")) + if err != nil { + return err + } + client := tui.NewClient(daemonURL(), key) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var payload json.RawMessage @@ -537,7 +541,7 @@ func exportCmd(runID, outFile string) error { fmt.Printf("audit export written to %s\n", outFile) return nil } - _, err := os.Stdout.Write(pretty.Bytes()) + _, err = os.Stdout.Write(pretty.Bytes()) return err } diff --git a/cmd/corral/main_test.go b/cmd/corral/main_test.go index ded942e..f4e708f 100644 --- a/cmd/corral/main_test.go +++ b/cmd/corral/main_test.go @@ -148,18 +148,39 @@ func TestDoctorPassesWithDaemonUp(t *testing.T) { } func TestExportCommand(t *testing.T) { - // Fake daemon serving a minimal export. + // Fake daemon serving a minimal export and recording the bearer key. payload := `{"runID":"run_1","status":"completed","events":[],"attempts":{},"artifacts":{}}` + var gotKey string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") w.Write([]byte(payload)) })) t.Cleanup(srv.Close) t.Setenv("CORRAL_DAEMON_URL", srv.URL) + t.Setenv("CORRAL_DAEMON_KEY", "") - outFile := filepath.Join(t.TempDir(), "audit.json") + // Serve the key from the repository, as status/tui/doctor do. + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".corral"), 0o755) + if err := os.WriteFile(filepath.Join(dir, ".corral", "api.key"), []byte("repo-key\n"), 0o600); err != nil { + t.Fatal(err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + outFile := filepath.Join(dir, "audit.json") if err := exportCmd("run_1", outFile); err != nil { t.Fatalf("export: %v", err) } + if gotKey != "repo-key" { + t.Fatalf("export used key %q, want repo key from .corral/api.key", gotKey) + } data, err := os.ReadFile(outFile) if err != nil { t.Fatal(err) @@ -169,6 +190,31 @@ func TestExportCommand(t *testing.T) { } } +func TestExportCommandEnvOverride(t *testing.T) { + // The env var must still override the repository key. + payload := `{"runID":"run_1","status":"completed","events":[],"attempts":{},"artifacts":{}}` + var gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + w.Write([]byte(payload)) + })) + t.Cleanup(srv.Close) + t.Setenv("CORRAL_DAEMON_URL", srv.URL) + t.Setenv("CORRAL_DAEMON_KEY", "env-key") + + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".corral"), 0o755) + os.WriteFile(filepath.Join(dir, ".corral", "api.key"), []byte("repo-key\n"), 0o600) + + outFile := filepath.Join(dir, "audit.json") + if err := exportCmd("run_1", outFile); err != nil { + t.Fatalf("export: %v", err) + } + if gotKey != "env-key" { + t.Fatalf("export used key %q, want env override", gotKey) + } +} + func TestInitMergesExistingOpenCodeConfig(t *testing.T) { dir := gitRepo(t) // Pre-existing opencode.json with custom settings must be preserved. From b21fb8bd7849d540c0cf52887118937370bed56c Mon Sep 17 00:00:00 2001 From: corral Date: Tue, 11 Aug 2026 11:39:17 -0300 Subject: [PATCH 07/26] corral: work --- README.md | 8 ++ cmd/corral/main.go | 62 +++++++- cmd/corral/main_test.go | 79 +++++++++++ docs/task5-worktrees.md | 24 +++- internal/worktree/worktree.go | 203 ++++++++++++++++++++++++++ internal/worktree/worktree_test.go | 219 +++++++++++++++++++++++++++++ 6 files changed, 590 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 23a0537..ff4a21c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ the seam for future executors. | `corral doctor` | Check OpenCode, Git, daemon, plugin, and config | | `corral update` | Install a newer GitHub release after a sanity check | | `corral export ` | Print the full audit export | +| `corral worktrees` | List attempt worktrees; `--prune` removes merged/removed and stale ones | `status`, `tui`, and `doctor` read the repository key automatically. Until the export command does the same, use: @@ -177,6 +178,13 @@ CORRAL_DAEMON_KEY="$(cat .corral/api.key)" \ corral export > audit.json ``` +`corral worktrees` works directly on git (no daemon, no key). It lists the +worktrees kept after failed attempts — path, branch, HEAD, and last-activity +time — and with `--prune` removes the ones that are safe to drop: branches +already merged into the main checkout, and (with `--stale `, e.g. +`24h`) worktrees idle longer than that. It never touches the main checkout; +locked worktrees are left alone. + ## Development ```sh diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 255cf04..5c99c54 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -5,6 +5,7 @@ // corral init one-command local initialization // corral doctor environment and daemon checks // corral export full audit export of a run +// corral worktrees list attempt worktrees; --prune drops safe ones package main import ( @@ -61,7 +62,7 @@ func main() { func run(args []string) error { if len(args) < 1 { - return fmt.Errorf("usage: corral [flags]") + return fmt.Errorf("usage: corral [flags]") } switch args[0] { case "update": @@ -94,8 +95,14 @@ func run(args []string) error { out = args[3] } return exportCmd(args[1], out) + case "worktrees": + fs := flag.NewFlagSet("worktrees", flag.ExitOnError) + prune := fs.Bool("prune", false, "prune worktrees whose branch was merged or removed") + stale := fs.Duration("stale", 0, "with --prune, also prune worktrees idle longer than this (e.g. 24h, 72h)") + fs.Parse(args[1:]) + return worktreesCmd(*prune, *stale) default: - return fmt.Errorf("unknown command %q (try: daemon, tui, up, init, doctor, export, update)", args[0]) + return fmt.Errorf("unknown command %q (try: daemon, tui, up, init, doctor, export, update, worktrees)", args[0]) } } @@ -541,6 +548,57 @@ func exportCmd(runID, outFile string) error { return err } +// worktreesCmd lists the attempt worktrees kept after failed attempts +// and, with --prune, removes ones that are safe to drop (merged or +// removed branches, plus stale ones beyond --stale). The main checkout +// is never touched. +func worktreesCmd(prune bool, stale time.Duration) error { + return worktreesCmdWithDir(dirOf(""), prune, stale) +} + +func worktreesCmdWithDir(dir string, prune bool, stale time.Duration) error { + wtm := worktree.NewManager(dir) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if prune { + removed, err := wtm.Prune(ctx, stale, time.Now()) + if err != nil { + return err + } + if len(removed) == 0 { + fmt.Println("nothing to prune") + return nil + } + for _, p := range removed { + fmt.Println("pruned", p) + } + return nil + } + infos, err := wtm.List(ctx) + if err != nil { + return err + } + if len(infos) == 0 { + fmt.Println("no attempt worktrees") + return nil + } + for _, info := range infos { + mark := "" + if info.Locked { + mark = " locked" + } + fmt.Printf("%-42s %-28s %-7s %s%s\n", info.Path, info.Branch, shortHead(info.Head), info.Mtime.Format("2006-01-02 15:04"), mark) + } + return nil +} + +func shortHead(h string) string { + if len(h) > 7 { + return h[:7] + } + return h +} + func versionAtLeast(v, min string) bool { re := regexp.MustCompile(`\d+\.\d+\.\d+`) m := re.FindString(v) diff --git a/cmd/corral/main_test.go b/cmd/corral/main_test.go index ded942e..1ed48de 100644 --- a/cmd/corral/main_test.go +++ b/cmd/corral/main_test.go @@ -16,6 +16,7 @@ import ( "corral/internal/livetest" "corral/internal/tui" + "corral/internal/worktree" ) // captureOutput runs fn with stdout redirected and returns its output. @@ -259,6 +260,84 @@ func TestUpCmdSpawnsHealthyDaemon(t *testing.T) { _ = exec.Command("pkill", "-f", "corral daemon --port "+fmt.Sprint(port)).Run() } +func TestWorktreesCommand(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + wtm := worktree.NewManager(repo) + path, err := wtm.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + out := captureOutput(t, func() { err = worktreesCmdWithDir(repo, false, 0) }) + if err != nil { + t.Fatalf("worktrees: %v", err) + } + if !strings.Contains(out, "corral/r1/w1/1") { + t.Fatalf("listing missing branch:\n%s", out) + } + if strings.Contains(out, "pruned") { + t.Fatalf("listing pruned unexpectedly:\n%s", out) + } + + // Nothing is merged or stale, so --prune removes nothing. + out = captureOutput(t, func() { err = worktreesCmdWithDir(repo, true, 0) }) + if err != nil { + t.Fatalf("worktrees --prune: %v", err) + } + if !strings.Contains(out, "nothing to prune") { + t.Fatalf("prune output wrong:\n%s", out) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("worktree dir removed: %v", err) + } +} + +func TestWorktreesCommandPrunesMerged(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + wtm := worktree.NewManager(repo) + path, err := wtm.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if err := wtm.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + if err := wtm.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatal(err) + } + + out := captureOutput(t, func() { err = worktreesCmdWithDir(repo, true, 0) }) + if err != nil { + t.Fatalf("worktrees --prune: %v", err) + } + if !strings.Contains(out, "pruned") || !strings.Contains(out, "corral/r1/w1/1") { + t.Fatalf("prune output wrong:\n%s", out) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("merged worktree dir not removed: %v", err) + } + // Main checkout untouched. + if data, err := os.ReadFile(filepath.Join(repo, "a.txt")); err != nil || string(data) != "hello" { + t.Fatalf("main checkout wrong: %v %q", err, data) + } +} + +func TestWorktreesCommandEmpty(t *testing.T) { + repo := gitRepo(t) + out := captureOutput(t, func() { _ = worktreesCmdWithDir(repo, false, 0) }) + if !strings.Contains(out, "no attempt worktrees") { + t.Fatalf("empty listing output wrong:\n%s", out) + } +} + func TestVersionAtLeast(t *testing.T) { cases := []struct { v, min string diff --git a/docs/task5-worktrees.md b/docs/task5-worktrees.md index 53ca431..c3976e2 100644 --- a/docs/task5-worktrees.md +++ b/docs/task5-worktrees.md @@ -3,7 +3,9 @@ Status: **DONE** — every writing node runs in its own git worktree; write scopes serialize conflicting writers; diffs become content-addressed artifacts; failed worktrees are kept; merges require checks + human -approval. Verified deterministically and against a real OpenCode server. +approval. `corral worktrees` lists retained worktrees and prunes +merged/removed or stale ones without touching the main checkout. Verified +deterministically and against a real OpenCode server. ## Deliverables @@ -16,6 +18,17 @@ approval. Verified deterministically and against a real OpenCode server. `MergeBranch` (no-ff into main), `Remove`, `MainBranch`, `Repo`. - `ScopesOverlap`: path-boundary prefix semantics; empty/`*` scope collides with everything. + - `List`: enumerates attempt worktrees under `.corral-worktrees` via + `git worktree list --porcelain`, with branch, HEAD, and last-activity + mtime (worktree dir + gitdir HEAD/index); the main checkout is never + listed. + - `BranchMerged`: reports whether a branch already has commits folded + into the main checkout branch (or no longer exists); a branch still at + the main tip is not "merged" so uncommitted work is never pruned. + - `Prune`: removes worktrees whose branch was merged/removed or whose + mtime exceeds a `staleAfter` threshold; sweeps orphaned git admin + entries via `git worktree prune`; skips locked worktrees; never touches + the main checkout. - `sched`: - Writing nodes (role `worker` or unlabeled agents) get a worktree per attempt; `Attempt.Cwd` and the attempt row record it (new @@ -62,6 +75,11 @@ approval. Verified deterministically and against a real OpenCode server. - Empty write scope on a writing node = whole repository (collides with everything); declare scopes to enable parallelism. - Worktrees are pruned only on successful merge; failed/abandoned - worktrees stay on disk for inspection (`git worktree prune` at run - cleanup is future work). + worktrees stay on disk for inspection. `corral worktrees` lists them + (path, branch, HEAD, mtime) and `corral worktrees --prune` removes the + ones that are safe to drop — branches already merged into main or + removed — plus, with `--prune --stale ` (e.g. `24h`), worktrees + idle longer than the threshold. Locked worktrees are never pruned, and + the main checkout is never touched. Stale-pruned branches are kept (their + commits stay recoverable) unless already merged. - Retries allocate a fresh worktree per attempt (deterministic redo). diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index ab5cbc6..e6238c2 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -8,11 +8,13 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "os/exec" "path/filepath" "strings" + "time" ) // Manager owns worktrees for one repository. @@ -52,6 +54,23 @@ func (m *Manager) git(ctx context.Context, dir string, args ...string) (string, return string(out), nil } +// gitExit runs git like git but returns the exit code instead of wrapping +// non-zero exits as errors (for commands whose exit code is meaningful, +// e.g. merge-base --is-ancestor). +func (m *Manager) gitExit(ctx context.Context, dir string, args ...string) (string, int, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err == nil { + return string(out), 0, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return string(out), ee.ExitCode(), nil + } + return "", -1, err +} + // Add creates a worktree for branch at a fresh path, based on the main // checkout HEAD. func (m *Manager) Add(ctx context.Context, branch string) (string, error) { @@ -157,6 +176,190 @@ func (m *Manager) Remove(ctx context.Context, branch string) error { return nil } +// WorktreeInfo describes one attempt worktree owned by this manager. +type WorktreeInfo struct { + Path string + Branch string // branch name without the refs/heads/ prefix + Head string // full commit hash the worktree is on + Mtime time.Time + Locked bool + Detached bool + Orphaned bool // admin entry whose working directory is already gone +} + +// List returns the attempt worktrees registered under the manager's +// sibling .corral-worktrees directory. Entries come from `git worktree +// list --porcelain`; the main checkout is never included. Mtime is the +// most recent change to the worktree directory or its gitdir HEAD/index. +func (m *Manager) List(ctx context.Context) ([]WorktreeInfo, error) { + out, err := m.git(ctx, m.repo, "worktree", "list", "--porcelain") + if err != nil { + return nil, err + } + var infos []WorktreeInfo + for _, block := range strings.Split(out, "\n\n") { + info, ok := parseWorktreeBlock(block) + if !ok || !insideDir(m.dir, info.Path) { + continue + } + info.Mtime = lastActivity(info.Path) + infos = append(infos, info) + } + return infos, nil +} + +func parseWorktreeBlock(block string) (WorktreeInfo, bool) { + var info WorktreeInfo + for _, line := range strings.Split(block, "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + info.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + case strings.HasPrefix(line, "HEAD "): + info.Head = strings.TrimSpace(strings.TrimPrefix(line, "HEAD ")) + case strings.HasPrefix(line, "branch refs/heads/"): + info.Branch = strings.TrimPrefix(line, "branch refs/heads/") + case line == "detached": + info.Detached = true + case line == "locked": + info.Locked = true + case strings.HasPrefix(line, "prunable"): + info.Orphaned = true + } + } + return info, info.Path != "" +} + +// insideDir reports whether path lives strictly below dir. Both sides are +// resolved through symlinks because git reports canonical paths while the +// caller may hold a symlinked one (e.g. /var vs /private/var on macOS). +func insideDir(dir, path string) bool { + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + return false + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return false + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// lastActivity returns the most recent modification time among the +// worktree directory and its gitdir HEAD/index files — a proxy for the +// last time anything changed in the worktree. +func lastActivity(path string) time.Time { + var latest time.Time + if fi, err := os.Stat(path); err == nil { + latest = fi.ModTime() + } + if gd := worktreeGitDir(path); gd != "" { + for _, f := range []string{"HEAD", "index"} { + if fi, err := os.Stat(filepath.Join(gd, f)); err == nil && fi.ModTime().After(latest) { + latest = fi.ModTime() + } + } + } + return latest +} + +// worktreeGitDir resolves the git directory backing a linked worktree by +// reading its .git file ("gitdir: "). Empty when unavailable. +func worktreeGitDir(path string) string { + data, err := os.ReadFile(filepath.Join(path, ".git")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if line, ok := strings.CutPrefix(line, "gitdir: "); ok { + return strings.TrimSpace(line) + } + } + return "" +} + +// BranchMerged reports whether branch has been folded into the main +// checkout branch with its own commits, or no longer exists. A branch +// still pointing at the main tip is not "merged": its worktree may hold +// uncommitted work. Neither check touches the main checkout. +func (m *Manager) BranchMerged(ctx context.Context, branch string) (bool, error) { + if _, code, err := m.gitExit(ctx, m.repo, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch); err != nil { + return false, err + } else if code != 0 { + return true, nil // branch removed; its worktree is orphaned + } + main, err := m.MainBranch(ctx) + if err != nil { + return false, err + } + branchTip, err := m.git(ctx, m.repo, "rev-parse", "--verify", "refs/heads/"+branch) + if err != nil { + return false, err + } + mainTip, err := m.git(ctx, m.repo, "rev-parse", "--verify", "refs/heads/"+main) + if err != nil { + return false, err + } + if strings.TrimSpace(branchTip) == strings.TrimSpace(mainTip) { + return false, nil // no unique commits; uncommitted work may still live in the worktree + } + _, code, err := m.gitExit(ctx, m.repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, "refs/heads/"+main) + if err != nil { + return false, err + } + return code == 0, nil +} + +// Prune removes worktrees that are safe to drop: their branch has been +// merged into the main checkout branch or no longer exists, or (when +// staleAfter > 0) their last activity is older than staleAfter. Locked +// worktrees are skipped; orphaned git admin entries are pruned first via +// `git worktree prune`. The main checkout is never touched. Returns the +// paths removed. +func (m *Manager) Prune(ctx context.Context, staleAfter time.Duration, now time.Time) ([]string, error) { + if _, err := m.git(ctx, m.repo, "worktree", "prune"); err != nil { + return nil, err + } + infos, err := m.List(ctx) + if err != nil { + return nil, err + } + var pruned []string + for _, info := range infos { + if info.Locked || info.Detached || info.Orphaned { + continue + } + merged, err := m.BranchMerged(ctx, info.Branch) + if err != nil { + return pruned, err + } + stale := staleAfter > 0 && now.Sub(info.Mtime) > staleAfter + if !merged && !stale { + continue + } + if err := m.removeInfo(ctx, info, merged); err != nil { + return pruned, fmt.Errorf("prune %s: %w", info.Path, err) + } + pruned = append(pruned, info.Path) + } + return pruned, nil +} + +// removeInfo removes a worktree by path. A merged branch is deleted too; +// an unmerged stale branch is kept so its commits stay recoverable. +func (m *Manager) removeInfo(ctx context.Context, info WorktreeInfo, merged bool) error { + if _, err := m.git(ctx, m.repo, "worktree", "remove", "--force", info.Path); err != nil { + return err + } + if merged && info.Branch != "" { + _, _ = m.git(ctx, m.repo, "branch", "-D", info.Branch) + } + return nil +} + // ScopesOverlap reports whether two declared write scopes can conflict. // Empty or "*" scopes mean the whole repository (a writing node with no // declared scope collides with everything). Paths overlap when one is a diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index c203f4e..022137a 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "testing" + "time" ) func gitInit(t *testing.T, dir string) { @@ -22,6 +23,17 @@ func gitInit(t *testing.T, dir string) { } } +// resolved returns the canonical (symlink-resolved) form of p, the form +// git reports on macOS (/var vs /private/var). +func resolved(t *testing.T, p string) string { + t.Helper() + r, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatal(err) + } + return r +} + func TestManagerLifecycle(t *testing.T) { repo := t.TempDir() gitInit(t, repo) @@ -88,6 +100,213 @@ func TestManagerLifecycle(t *testing.T) { } } +func TestManagerList(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + if infos, err := m.List(ctx); err != nil { + t.Fatalf("list: %v", err) + } else if len(infos) != 0 { + t.Fatalf("List = %v, want none", infos) + } + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + infos, err := m.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(infos) != 1 { + t.Fatalf("List = %v, want 1 worktree", infos) + } + info := infos[0] + if info.Branch != "corral/r1/w1/1" { + t.Fatalf("branch = %q", info.Branch) + } + if info.Path != resolved(t, path) { + t.Fatalf("path = %q, want %q", info.Path, resolved(t, path)) + } + if info.Head == "" { + t.Fatal("head empty") + } + if info.Mtime.IsZero() { + t.Fatal("mtime zero") + } + if info.Detached || info.Locked || info.Orphaned { + t.Fatalf("unexpected flags: %+v", info) + } + // The main checkout is never listed. + for _, i := range infos { + if i.Path == repo { + t.Fatalf("main checkout listed: %+v", i) + } + } +} + +func TestManagerPruneMerged(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatal(err) + } + merged, err := m.BranchMerged(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if !merged { + t.Fatal("merged branch should report merged") + } + want := resolved(t, path) + + pruned, err := m.Prune(ctx, 0, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(pruned) != 1 || pruned[0] != want { + t.Fatalf("pruned = %v, want [%s]", pruned, want) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("worktree dir still exists") + } + if infos, err := m.List(ctx); err != nil || len(infos) != 0 { + t.Fatalf("List after prune = %v (err %v)", infos, err) + } + // Main checkout keeps the merged content and is untouched. + data, err := os.ReadFile(filepath.Join(repo, "a.txt")) + if err != nil || string(data) != "hello" { + t.Fatalf("main checkout wrong: %v %q", err, data) + } +} + +func TestManagerPruneStale(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + now := time.Now() + + // An unmerged worktree survives --prune alone (age criterion off). + if pruned, err := m.Prune(ctx, 0, now); err != nil || len(pruned) != 0 { + t.Fatalf("prune without stale removed %v (err %v)", pruned, err) + } + // It survives --prune --stale while fresh. + if pruned, err := m.Prune(ctx, 24*time.Hour, now); err != nil || len(pruned) != 0 { + t.Fatalf("fresh prune removed %v (err %v)", pruned, err) + } + // Idle past the threshold: removed, but its branch is kept. + want := resolved(t, path) + pruned, err := m.Prune(ctx, 24*time.Hour, now.Add(30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(pruned) != 1 || pruned[0] != want { + t.Fatalf("stale pruned = %v, want [%s]", pruned, want) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("stale worktree dir still exists") + } + if _, code, _ := m.gitExit(ctx, m.repo, "rev-parse", "--verify", "--quiet", "refs/heads/corral/r1/w1/1"); code != 0 { + t.Fatal("unmerged stale branch should be kept") + } +} + +func TestManagerPruneSkipsLocked(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, m.repo, "worktree", "lock", path); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatal(err) + } + // Merged, but locked: never pruned. + if pruned, err := m.Prune(ctx, 0, time.Now()); err != nil || len(pruned) != 0 { + t.Fatalf("locked worktree pruned: %v (err %v)", pruned, err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("locked worktree dir removed: %v", err) + } +} + +func TestManagerPruneOrphaned(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + // The admin entry still exists until a prune sweeps it. + if _, err := m.Prune(ctx, 0, time.Now()); err != nil { + t.Fatal(err) + } + if infos, err := m.List(ctx); err != nil || len(infos) != 0 { + t.Fatalf("orphaned worktree not cleaned: %v (err %v)", infos, err) + } +} + +func TestManagerBranchMergedMissing(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + merged, err := m.BranchMerged(ctx, "corral/r1/never/1") + if err != nil { + t.Fatal(err) + } + if !merged { + t.Fatal("missing branch should report merged (safe to prune)") + } +} + func TestScopesOverlap(t *testing.T) { cases := []struct { a, b []string From 2842fa0c797902bf8802d47d8fce78032180c57b Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 12:03:49 -0300 Subject: [PATCH 08/26] corral: align tests with CreateOptions design --- internal/daemon/daemon.go | 8 ++ internal/daemon/daemon_test.go | 142 +++++++++++---------------------- internal/sched/gates_test.go | 2 +- internal/sched/sched.go | 36 +++++++-- internal/store/store_test.go | 6 +- 5 files changed, 91 insertions(+), 103 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index dbb8d1d..583111a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -382,6 +382,14 @@ func (d *Daemon) watchSnapshot(ctx context.Context, id string, since int64) (map } done = ru.Status != "active" } + // The run status above was read before done was resolved; the store is + // finalized before the handle reports done, so re-read it so the + // snapshot never carries done=true with a stale status. + if done && ru.Status == "active" { + if ru2, err := d.st.Run(ctx, id); err == nil { + ru.Status = ru2.Status + } + } var gates []string for _, n := range ru.Graph.Nodes { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 9e1de79..d12cb2f 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -1,11 +1,9 @@ package daemon_test import ( - "bufio" "bytes" "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -332,10 +330,10 @@ func TestAutoApproveGatesThroughAPI(t *testing.T) { } } -// TestWatchStreamsEvents drives the run through the SSE watch stream: it -// must report the gate awaiting approval, and a done frame once the run -// completes after the operator approves. -func TestWatchStreamsEvents(t *testing.T) { +// TestWatchReportsGateAndDone drives the run through the watch endpoint +// (long-poll JSON): it must report the gate awaiting approval, and a done +// snapshot once the run completes after the operator approves. +func TestWatchReportsGateAndDone(t *testing.T) { a, _, _, drv := setupDaemon(t, "") drv.SetScript("w1", sched.Script{Delay: 100 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) g := &graph.Graph{Nodes: []*graph.Node{ @@ -349,47 +347,36 @@ func TestWatchStreamsEvents(t *testing.T) { var created struct{ RunID string } json.Unmarshal([]byte(body), &created) - resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/watch") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { - t.Fatalf("content-type = %q, want text/event-stream", ct) - } - r := bufio.NewReader(resp.Body) - - // The worker runs and the gate parks awaiting approval — the stream - // must flag it. - f, err := sseUntil(r, 30*time.Second, func(m map[string]any) bool { - g, _ := m["gate"].(bool) - aa, _ := m["awaitingApproval"].(bool) - return g && aa + // The worker runs and the gate parks awaiting approval — the watch + // snapshot must flag it. + snap := a.watchUntil(t, created.RunID, "", func(m map[string]any) bool { + gates, _ := m["gatesAwaitingApproval"].([]any) + return len(gates) == 1 && gates[0] == "gate" }) - if err != nil { - t.Fatalf("gate frame: %v", err) + if st, _ := snap["states"].(map[string]any)["gate"].(string); st != string(graph.StateRunning) { + t.Fatalf("gate state = %v, want running", st) } - if f["nodeID"] != "gate" || f["to"] != "running" { - t.Fatalf("gate frame = %v, want nodeID gate to running", f) + if aa, _ := snap["autoApproveGates"].(bool); aa { + t.Fatal("autoApproveGates set on a default run") } - // Approve via the API; the run completes and the stream emits done. + // Approve via the API; the run completes and the watch reports done. code, body = a.do("operator", http.MethodPost, "/api/runs/"+created.RunID+"/approve", map[string]any{"nodeID": "gate"}) if code != http.StatusOK { t.Fatalf("approve: %d %s", code, body) } - f, err = sseUntil(r, 30*time.Second, func(m map[string]any) bool { return m["type"] == "done" }) - if err != nil { - t.Fatalf("done frame: %v", err) - } - if f["status"] != "completed" { - t.Fatalf("done status = %v, want completed", f["status"]) + snap = a.watchUntil(t, created.RunID, "", func(m map[string]any) bool { + d, _ := m["done"].(bool) + return d + }) + if s, _ := snap["status"].(string); s != "completed" { + t.Fatalf("done status = %v, want completed", snap["status"]) } } // TestWatchReportsDoneForSettledRun opens the watch stream after a run -// already settled: with no pending events the endpoint emits an immediate -// done frame (and the `after` cursor is honored). +// already settled: with no events after the cursor the endpoint still +// reports the done snapshot immediately. func TestWatchReportsDoneForSettledRun(t *testing.T) { a, _, _, drv := setupDaemon(t, "") drv.SetScript("w1", sched.Script{Delay: 50 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) @@ -402,70 +389,37 @@ func TestWatchReportsDoneForSettledRun(t *testing.T) { json.Unmarshal([]byte(body), &created) a.waitState(t, "", created.RunID, "w1", graph.StateDone, 30*time.Second) - resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/watch?after=999999") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - r := bufio.NewReader(resp.Body) - f, err := sseUntil(r, 10*time.Second, func(m map[string]any) bool { return m["type"] == "done" }) - if err != nil { - t.Fatalf("done frame: %v", err) - } - if f["status"] != "completed" { - t.Fatalf("done status = %v, want completed", f["status"]) - } -} - -// sseFrame reads one SSE data frame (data lines up to the blank line) -// and parses it as JSON. -func sseFrame(r *bufio.Reader, timeout time.Duration) (map[string]any, error) { - type res struct { - m map[string]any - err error - } - ch := make(chan res, 1) - go func() { - var data []string - for { - line, err := r.ReadString('\n') - if err != nil { - ch <- res{err: err} - return - } - trimmed := strings.TrimSpace(line) - if trimmed == "" && len(data) > 0 { - var m map[string]any - if err := json.Unmarshal([]byte(strings.Join(data, "")), &m); err != nil { - ch <- res{err: fmt.Errorf("sse decode: %w", err)} - return - } - ch <- res{m: m} - return - } - if strings.HasPrefix(trimmed, "data:") { - data = append(data, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) - } - } - }() - select { - case r := <-ch: - return r.m, r.err - case <-time.After(timeout): - return nil, fmt.Errorf("timed out waiting for SSE frame") + snap := a.watchUntil(t, created.RunID, "999999", func(m map[string]any) bool { + d, _ := m["done"].(bool) + return d + }) + if s, _ := snap["status"].(string); s != "completed" { + t.Fatalf("done status = %v, want completed", snap["status"]) } } -// sseUntil consumes SSE frames until one satisfies pred. -func sseUntil(r *bufio.Reader, timeout time.Duration, pred func(map[string]any) bool) (map[string]any, error) { - deadline := time.Now().Add(timeout) +// watchUntil long-polls /watch until a snapshot satisfies pred. +func (a *api) watchUntil(t *testing.T, runID, since string, pred func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.Now().Add(30 * time.Second) for { - frame, err := sseFrame(r, time.Until(deadline)) - if err != nil { - return nil, err + q := "timeout=1" + if since != "" { + q += "&since=" + since + } + code, body := a.do("operator", http.MethodGet, "/api/runs/"+runID+"/watch?"+q, nil) + if code != http.StatusOK { + t.Fatalf("watch: %d %s", code, body) + } + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("watch decode: %v", err) + } + if pred(m) { + return m } - if pred(frame) { - return frame, nil + if time.Now().After(deadline) { + t.Fatalf("watch timed out waiting for snapshot: %s", body) } } } diff --git a/internal/sched/gates_test.go b/internal/sched/gates_test.go index f4f8894..d97fea8 100644 --- a/internal/sched/gates_test.go +++ b/internal/sched/gates_test.go @@ -144,7 +144,7 @@ func TestAutoApproveGatesSkipsOperator(t *testing.T) { agent("w"), {ID: "gate", Type: graph.NodeHuman, Objective: "approve", Priority: graph.PriorityNormal, DependsOn: []graph.NodeID{"w"}}, }} - h, err := s.CreateWithOptions(context.Background(), "run-autoapprove", g, sched.RunOptions{AutoApproveGates: true}) + h, err := s.Create(context.Background(), "run-autoapprove", g, sched.CreateOptions{AutoApproveGates: true}) if err != nil { t.Fatal(err) } diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 87fa459..2bc29aa 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -131,6 +131,8 @@ type RunHandle struct { done bool stepCount int64 started time.Time + + autoApproveGates bool } // CreateOptions carries per-run creation policies (extended without @@ -156,7 +158,7 @@ func (s *Scheduler) Create(ctx context.Context, runID string, g *graph.Graph, op if err := s.store.CreateRun(ctx, runID, g, o.AutoApproveGates, now); err != nil { return nil, err } - return s.newHandle(ctx, runID, g, now) + return s.newHandle(ctx, runID, g, now, o.AutoApproveGates) } // Load resumes a persisted run: events are replayed into a fresh tracker @@ -237,6 +239,8 @@ func (s *Scheduler) Load(ctx context.Context, runID string) (*RunHandle, error) results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, + + autoApproveGates: r.AutoApproveGates, } for _, ev := range events { if ev.Type == store.EventRetry { @@ -267,7 +271,7 @@ func retryReadyAt(events []store.Event, nodeID graph.NodeID) (time.Time, bool) { return time.Time{}, false } -func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time) (*RunHandle, error) { +func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time, autoApproveGates bool) (*RunHandle, error) { tr, err := graph.NewTracker(g) if err != nil { return nil, err @@ -285,6 +289,8 @@ func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, + + autoApproveGates: autoApproveGates, }, nil } @@ -802,7 +808,8 @@ func (h *RunHandle) startMerge(ctx context.Context, n *graph.Node, attemptID str } // startGate parks a human gate node in running until an operator approves -// or rejects it. No driver session is involved. +// or rejects it. No driver session is involved. When the run was created +// with autoApproveGates the gate is approved immediately instead. func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID string, no int, now time.Time) error { sess := &gateSession{id: "gate:" + string(n.ID)} started := now.UnixMilli() @@ -821,8 +828,27 @@ func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID stri }); err != nil { return err } - return h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, - `{"phase":"start","sessionID":"`+sess.ID()+`"}`) + if err := h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, + `{"phase":"start","sessionID":"`+sess.ID()+`"}`); err != nil { + return err + } + if !h.autoApproveGates { + return nil + } + // Auto-approve: no operator round-trip; the gate passes through the + // evidence machine (running → verifying → done) like a manual approve. + delete(h.sessions, n.ID) + finished := now.UnixMilli() + if err := h.transit(ctx, n.ID, graph.StateRunning, graph.StateVerifying, ""); err != nil { + return err + } + if err := h.transit(ctx, n.ID, graph.StateVerifying, graph.StateDone, ""); err != nil { + return err + } + return h.s.store.RecordAttempt(ctx, store.Attempt{ + ID: attemptID, RunID: h.runID, NodeID: string(n.ID), No: no, + Status: "done", SessionID: sess.ID(), StartedAt: &started, FinishedAt: &finished, + }) } // completeInline registers the session, transitions to running, records diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 91dbe90..6f522c1 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -161,7 +161,7 @@ func TestMigrateAddsAutoApproveColumn(t *testing.T) { } t.Cleanup(func() { st.Close() }) ctx := context.Background() - if err := st.CreateRun(ctx, "r1", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } ru, err := st.Run(ctx, "r1") @@ -177,11 +177,11 @@ func TestAutoApproveGatesPersisted(t *testing.T) { st := open(t) ctx := context.Background() // Default run: flag off. - if err := st.CreateRun(ctx, "off", testGraph(t), now()); err != nil { + if err := st.CreateRun(ctx, "off", testGraph(t), false, now()); err != nil { t.Fatal(err) } // Explicit run: flag on. - if err := st.CreateRunWithOpts(ctx, "on", testGraph(t), true, now()); err != nil { + if err := st.CreateRun(ctx, "on", testGraph(t), true, now()); err != nil { t.Fatal(err) } off, err := st.Run(ctx, "off") From 93a0c56d82787313e18ae9fcca85bbc9e31d6d72 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:07:40 -0300 Subject: [PATCH 09/26] fix: scope attempt IDs by run Attempt IDs previously collided whenever separate runs used the same node and attempt number. Prefix IDs with the run so the global attempts primary key cannot silently redirect later run records. --- internal/ocxreviewer/reviewer_test.go | 2 +- internal/sched/sched.go | 2 +- internal/sched/sched_test.go | 34 +++++++++++++++++++++++++++ internal/store/redact_test.go | 8 +++---- internal/store/store_test.go | 18 +++++++------- internal/tui/tui_test.go | 4 ++-- 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/internal/ocxreviewer/reviewer_test.go b/internal/ocxreviewer/reviewer_test.go index e406f7d..cf0beb6 100644 --- a/internal/ocxreviewer/reviewer_test.go +++ b/internal/ocxreviewer/reviewer_test.go @@ -90,7 +90,7 @@ func llmError(name string) []ocx.Message { func reviewReq(worktree string) verify.ReviewRequest { return verify.ReviewRequest{ Attempt: adapter.Attempt{ - ID: "w1/1", + ID: "run_1/w1/1", NodeID: "w1", Objective: "create manifest.json with a name field", Role: "worker", diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 2bc29aa..c251d1c 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -534,7 +534,7 @@ func (h *RunHandle) startAttempt(ctx context.Context, n *graph.Node) error { return err } no++ - attemptID := fmt.Sprintf("%s/%d", n.ID, no) + attemptID := fmt.Sprintf("%s/%s/%d", h.runID, n.ID, no) switch n.Type { case graph.NodeCheck: return h.startCheck(ctx, n, attemptID, no, now) diff --git a/internal/sched/sched_test.go b/internal/sched/sched_test.go index 0b54b35..f6a52b6 100644 --- a/internal/sched/sched_test.go +++ b/internal/sched/sched_test.go @@ -302,6 +302,40 @@ func TestRetryExhaustedFails(t *testing.T) { } } +func TestAttemptIDsAreUniqueAcrossRuns(t *testing.T) { + st := newStore(t) + clk := fakeClock() + ctx := context.Background() + + for _, runID := range []string{"run-first", "run-second"} { + drv := sched.NewFakeDriver(clk, scriptsFor("worker")) + ver := sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}) + s := newSched(t, st, drv, ver, clk, sched.Options{Concurrency: 1}) + h, err := s.Create(ctx, runID, &graph.Graph{Nodes: []*graph.Node{agent("worker")}}) + if err != nil { + t.Fatal(err) + } + drive(t, h, clk, 10) + if !h.Done() { + t.Fatalf("%s did not complete", runID) + } + } + + for _, runID := range []string{"run-first", "run-second"} { + attempts, err := st.Attempts(ctx, runID, "worker") + if err != nil { + t.Fatal(err) + } + if len(attempts) != 1 { + t.Fatalf("%s attempts = %d, want 1", runID, len(attempts)) + } + wantID := runID + "/worker/1" + if attempts[0].ID != wantID { + t.Fatalf("%s attempt ID = %q, want %q", runID, attempts[0].ID, wantID) + } + } +} + func TestBudgetAbortFails(t *testing.T) { st := newStore(t) clk := fakeClock() diff --git a/internal/store/redact_test.go b/internal/store/redact_test.go index 816adf8..3c20f9c 100644 --- a/internal/store/redact_test.go +++ b/internal/store/redact_test.go @@ -45,18 +45,18 @@ func TestSecretsNeverPersisted(t *testing.T) { secret := "Bearer sk-verysecretkey1234567890abcdef" ts := time.Now().UnixMilli() if err := st.RecordAttempt(ctx, Attempt{ - ID: "w1/1", RunID: "r1", NodeID: "w1", No: 1, Status: "done", + ID: "r1/w1/1", RunID: "r1", NodeID: "w1", No: 1, Status: "done", Evidence: "verifier saw " + secret, FinishedAt: &ts, }); err != nil { t.Fatal(err) } if err := st.RecordArtifact(ctx, Artifact{ - RunID: "r1", AttemptID: "w1/1", NodeID: "w1", Name: "diff", + RunID: "r1", AttemptID: "r1/w1/1", NodeID: "w1", Name: "diff", Hash: "h", Content: "output with " + secret, }); err != nil { t.Fatal(err) } - if _, err := st.AppendEvent(ctx, "r1", "w1", EventVerdict, "", "", "w1/1", `{"note":"`+secret+`"}`, time.Now()); err != nil { + if _, err := st.AppendEvent(ctx, "r1", "w1", EventVerdict, "", "", "r1/w1/1", `{"note":"`+secret+`"}`, time.Now()); err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestSecretsNeverPersisted(t *testing.T) { if strings.Contains(atts[0].Evidence, "sk-verysecretkey") { t.Fatalf("evidence leaked secret: %q", atts[0].Evidence) } - arts, _ := st.Artifacts(ctx, "r1", "w1/1") + arts, _ := st.Artifacts(ctx, "r1", "r1/w1/1") if strings.Contains(arts[0].Content, "sk-verysecretkey") { t.Fatalf("artifact leaked secret: %q", arts[0].Content) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6f522c1..dd5a7a5 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -116,22 +116,22 @@ func TestAttemptsUniquePerNode(t *testing.T) { if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } - a := Attempt{ID: "a/1", RunID: "r1", NodeID: "a", No: 1, Status: "running"} + a := Attempt{ID: "r1/a/1", RunID: "r1", NodeID: "a", No: 1, Status: "running"} if err := st.RecordAttempt(ctx, a); err != nil { t.Fatal(err) } - if err := st.RecordAttempt(ctx, Attempt{ID: "a/2", RunID: "r1", NodeID: "a", No: 2, Status: "running"}); err != nil { + if err := st.RecordAttempt(ctx, Attempt{ID: "r1/a/2", RunID: "r1", NodeID: "a", No: 2, Status: "running"}); err != nil { t.Fatal(err) } // Duplicate attempt number for the same node must fail. - if err := st.RecordAttempt(ctx, Attempt{ID: "a/2b", RunID: "r1", NodeID: "a", No: 2, Status: "running"}); err == nil { + if err := st.RecordAttempt(ctx, Attempt{ID: "r1/a/2b", RunID: "r1", NodeID: "a", No: 2, Status: "running"}); err == nil { t.Fatal("duplicate (node, no) accepted") } atts, err := st.Attempts(ctx, "r1", "a") if err != nil { t.Fatal(err) } - if len(atts) != 2 || atts[0].ID != "a/1" || atts[1].ID != "a/2" { + if len(atts) != 2 || atts[0].ID != "r1/a/1" || atts[1].ID != "r1/a/2" { t.Fatalf("attempts wrong: %+v", atts) } } @@ -220,12 +220,12 @@ func TestMarkInterrupted(t *testing.T) { if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { t.Fatal(err) } - for _, id := range []string{"a/1", "b/1"} { - if err := st.RecordAttempt(ctx, Attempt{ID: id, RunID: "r1", NodeID: string(id[0]), No: 1, Status: "running"}); err != nil { + for _, attempt := range []struct{ id, nodeID string }{{"r1/a/1", "a"}, {"r1/b/1", "b"}} { + if err := st.RecordAttempt(ctx, Attempt{ID: attempt.id, RunID: "r1", NodeID: attempt.nodeID, No: 1, Status: "running"}); err != nil { t.Fatal(err) } } - if err := st.RecordAttempt(ctx, Attempt{ID: "a/0", RunID: "r1", NodeID: "a", No: 0, Status: "done"}); err != nil { + if err := st.RecordAttempt(ctx, Attempt{ID: "r1/a/0", RunID: "r1", NodeID: "a", No: 0, Status: "done"}); err != nil { t.Fatal(err) } if err := st.MarkInterrupted(ctx, "r1", "a"); err != nil { @@ -233,12 +233,12 @@ func TestMarkInterrupted(t *testing.T) { } atts, _ := st.Attempts(ctx, "r1", "a") for _, at := range atts { - if at.ID == "a/0" && at.Status != "done" { + if at.ID == "r1/a/0" && at.Status != "done" { t.Fatalf("completed attempt flipped: %+v", at) } } for _, at := range atts { - if at.ID == "a/1" && at.Status != "interrupted" { + if at.ID == "r1/a/1" && at.Status != "interrupted" { t.Fatalf("running attempt not interrupted: %+v", at) } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5a3de8e..166c00b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -70,8 +70,8 @@ func sampleDetail() *RunDetail { }}, States: map[string]string{"w1": "done", "gate": "running", "m": "pending"}, Attempts: map[string][]AttemptView{ - "w1": {{ID: "w1/1", No: 1, Status: "done", SessionID: "ses_x", Worktree: "/tmp/wt/w1", Evidence: `{"exit":0}`}}, - "gate": {{ID: "gate/1", No: 1, Status: "running"}}, + "w1": {{ID: "run_1/w1/1", No: 1, Status: "done", SessionID: "ses_x", Worktree: "/tmp/wt/w1", Evidence: `{"exit":0}`}}, + "gate": {{ID: "run_1/gate/1", No: 1, Status: "running"}}, }, Events: []EventView{{Seq: 1, Type: "transition", NodeID: "w1", From: "pending", To: "done"}}, } From e1c9079f90c24ef0ea5fc2290f17cdaa1bced340 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:16:29 -0300 Subject: [PATCH 10/26] chore: ignore generated OpenCode config Corral init writes the project-local config while tracked examples and embedded assets remain canonical. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 21ee81c..be8d389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bin/ .DS_Store .corral/ /corral +/opencode.json From 9e1fa7e99edc6c7987df71f816d686e5405ce003 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:34:28 -0300 Subject: [PATCH 11/26] fix: preserve dirty worktrees during prune --- README.md | 12 +++--- cmd/corral/main.go | 19 ++++++--- docs/task5-worktrees.md | 29 ++++++------- internal/worktree/worktree.go | 20 +++++---- internal/worktree/worktree_test.go | 67 ++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8dcf345..3c80416 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ the seam for future executors. | `corral doctor` | Check OpenCode, Git, daemon, plugin, and config | | `corral update` | Install a newer GitHub release after a sanity check | | `corral export ` | Print the full audit export | -| `corral worktrees` | List attempt worktrees; `--prune` removes merged/removed and stale ones | +| `corral worktrees` | List attempt worktrees; `--prune` removes clean merged/removed and stale ones | `status`, `tui`, `doctor`, and `export` read the repository key automatically. @@ -199,11 +199,11 @@ corral up ``` `corral worktrees` works directly on git (no daemon, no key). It lists the -worktrees kept after failed attempts — path, branch, HEAD, and last-activity -time — and with `--prune` removes the ones that are safe to drop: branches -already merged into the main checkout, and (with `--stale `, e.g. -`24h`) worktrees idle longer than that. It never touches the main checkout; -locked worktrees are left alone. +worktrees kept after failed attempts — path, branch, HEAD, last-activity time, +and dirty/locked markers — and with `--prune` removes clean ones that are safe +to drop: branches already merged into the main checkout, and (with `--stale +`, e.g. `24h`) worktrees idle longer than that. It never touches the +main checkout; dirty, locked, and detached worktrees are left alone. ## Development diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 0ca6dfa..4cb0351 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -98,7 +98,7 @@ func run(args []string) error { return exportCmd(args[1], out) case "worktrees": fs := flag.NewFlagSet("worktrees", flag.ExitOnError) - prune := fs.Bool("prune", false, "prune worktrees whose branch was merged or removed") + prune := fs.Bool("prune", false, "prune clean worktrees whose branch was merged or removed") stale := fs.Duration("stale", 0, "with --prune, also prune worktrees idle longer than this (e.g. 24h, 72h)") fs.Parse(args[1:]) return worktreesCmd(*prune, *stale) @@ -598,9 +598,9 @@ func exportCmd(runID, outFile string) error { } // worktreesCmd lists the attempt worktrees kept after failed attempts -// and, with --prune, removes ones that are safe to drop (merged or -// removed branches, plus stale ones beyond --stale). The main checkout -// is never touched. +// and, with --prune, removes clean ones that are safe to drop (merged or +// removed branches, plus stale ones beyond --stale). Dirty worktrees and +// the main checkout are never touched. func worktreesCmd(prune bool, stale time.Duration) error { return worktreesCmdWithDir(dirOf(""), prune, stale) } @@ -632,9 +632,16 @@ func worktreesCmdWithDir(dir string, prune bool, stale time.Duration) error { return nil } for _, info := range infos { - mark := "" + var marks []string + if info.Dirty { + marks = append(marks, "dirty") + } if info.Locked { - mark = " locked" + marks = append(marks, "locked") + } + mark := "" + if len(marks) > 0 { + mark = " " + strings.Join(marks, ",") } fmt.Printf("%-42s %-28s %-7s %s%s\n", info.Path, info.Branch, shortHead(info.Head), info.Mtime.Format("2006-01-02 15:04"), mark) } diff --git a/docs/task5-worktrees.md b/docs/task5-worktrees.md index c3976e2..bcce2bb 100644 --- a/docs/task5-worktrees.md +++ b/docs/task5-worktrees.md @@ -3,7 +3,7 @@ Status: **DONE** — every writing node runs in its own git worktree; write scopes serialize conflicting writers; diffs become content-addressed artifacts; failed worktrees are kept; merges require checks + human -approval. `corral worktrees` lists retained worktrees and prunes +approval. `corral worktrees` lists retained worktrees and prunes clean merged/removed or stale ones without touching the main checkout. Verified deterministically and against a real OpenCode server. @@ -19,16 +19,16 @@ deterministically and against a real OpenCode server. - `ScopesOverlap`: path-boundary prefix semantics; empty/`*` scope collides with everything. - `List`: enumerates attempt worktrees under `.corral-worktrees` via - `git worktree list --porcelain`, with branch, HEAD, and last-activity - mtime (worktree dir + gitdir HEAD/index); the main checkout is never - listed. + `git worktree list --porcelain`, with branch, HEAD, last-activity mtime + (worktree dir + gitdir HEAD/index), and dirty state; the main checkout + is never listed. - `BranchMerged`: reports whether a branch already has commits folded into the main checkout branch (or no longer exists); a branch still at the main tip is not "merged" so uncommitted work is never pruned. - - `Prune`: removes worktrees whose branch was merged/removed or whose - mtime exceeds a `staleAfter` threshold; sweeps orphaned git admin - entries via `git worktree prune`; skips locked worktrees; never touches - the main checkout. + - `Prune`: removes clean worktrees whose branch was merged/removed or + whose mtime exceeds a `staleAfter` threshold; sweeps orphaned git admin + entries via `git worktree prune`; skips dirty, locked, and detached + worktrees; never touches the main checkout. - `sched`: - Writing nodes (role `worker` or unlabeled agents) get a worktree per attempt; `Attempt.Cwd` and the attempt row record it (new @@ -76,10 +76,11 @@ deterministically and against a real OpenCode server. everything); declare scopes to enable parallelism. - Worktrees are pruned only on successful merge; failed/abandoned worktrees stay on disk for inspection. `corral worktrees` lists them - (path, branch, HEAD, mtime) and `corral worktrees --prune` removes the - ones that are safe to drop — branches already merged into main or - removed — plus, with `--prune --stale ` (e.g. `24h`), worktrees - idle longer than the threshold. Locked worktrees are never pruned, and - the main checkout is never touched. Stale-pruned branches are kept (their - commits stay recoverable) unless already merged. + (path, branch, HEAD, mtime, dirty/locked state) and `corral worktrees + --prune` removes clean ones that are safe to drop — branches already + merged into main or removed — plus, with `--prune --stale ` + (e.g. `24h`), clean worktrees idle longer than the threshold. Dirty, + locked, and detached worktrees are never pruned, and the main checkout is + never touched. Stale-pruned branches are kept (their commits stay + recoverable) unless already merged. - Retries allocate a fresh worktree per attempt (deterministic redo). diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index e6238c2..14dab2a 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -185,6 +185,7 @@ type WorktreeInfo struct { Locked bool Detached bool Orphaned bool // admin entry whose working directory is already gone + Dirty bool // tracked, staged, or untracked work not recorded in HEAD } // List returns the attempt worktrees registered under the manager's @@ -203,6 +204,11 @@ func (m *Manager) List(ctx context.Context) ([]WorktreeInfo, error) { continue } info.Mtime = lastActivity(info.Path) + status, err := m.git(ctx, info.Path, "status", "--porcelain=v1", "--untracked-files=normal", "--ignored=no") + if err != nil { + return nil, err + } + info.Dirty = strings.TrimSpace(status) != "" infos = append(infos, info) } return infos, nil @@ -313,12 +319,12 @@ func (m *Manager) BranchMerged(ctx context.Context, branch string) (bool, error) return code == 0, nil } -// Prune removes worktrees that are safe to drop: their branch has been -// merged into the main checkout branch or no longer exists, or (when -// staleAfter > 0) their last activity is older than staleAfter. Locked -// worktrees are skipped; orphaned git admin entries are pruned first via -// `git worktree prune`. The main checkout is never touched. Returns the -// paths removed. +// Prune removes clean worktrees that are safe to drop: their branch has +// been merged into the main checkout branch or no longer exists, or (when +// staleAfter > 0) their last activity is older than staleAfter. Dirty, +// locked, and detached worktrees are always skipped; orphaned git admin +// entries are pruned first via `git worktree prune`. The main checkout is +// never touched. Returns the paths removed. func (m *Manager) Prune(ctx context.Context, staleAfter time.Duration, now time.Time) ([]string, error) { if _, err := m.git(ctx, m.repo, "worktree", "prune"); err != nil { return nil, err @@ -329,7 +335,7 @@ func (m *Manager) Prune(ctx context.Context, staleAfter time.Duration, now time. } var pruned []string for _, info := range infos { - if info.Locked || info.Detached || info.Orphaned { + if info.Dirty || info.Locked || info.Detached || info.Orphaned { continue } merged, err := m.BranchMerged(ctx, info.Branch) diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 022137a..d5577cf 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -140,6 +140,9 @@ func TestManagerList(t *testing.T) { if info.Mtime.IsZero() { t.Fatal("mtime zero") } + if !info.Dirty { + t.Fatal("dirty worktree reported clean") + } if info.Detached || info.Locked || info.Orphaned { t.Fatalf("unexpected flags: %+v", info) } @@ -199,6 +202,41 @@ func TestManagerPruneMerged(t *testing.T) { } } +func TestManagerPruneSkipsDirtyMerged(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "merged.txt"), []byte("merged"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "unfinished.txt"), []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + pruned, err := m.Prune(ctx, 0, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(pruned) != 0 { + t.Fatalf("dirty merged worktree pruned: %v", pruned) + } + if data, err := os.ReadFile(filepath.Join(path, "unfinished.txt")); err != nil || string(data) != "keep me" { + t.Fatalf("uncommitted work lost: %q (%v)", data, err) + } +} + func TestManagerPruneStale(t *testing.T) { repo := t.TempDir() gitInit(t, repo) @@ -212,6 +250,9 @@ func TestManagerPruneStale(t *testing.T) { if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } now := time.Now() // An unmerged worktree survives --prune alone (age criterion off). @@ -239,6 +280,32 @@ func TestManagerPruneStale(t *testing.T) { } } +func TestManagerPruneSkipsDirtyStale(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "unfinished.txt"), []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + pruned, err := m.Prune(ctx, time.Hour, time.Now().Add(30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(pruned) != 0 { + t.Fatalf("dirty stale worktree pruned: %v", pruned) + } + if _, err := os.Stat(filepath.Join(path, "unfinished.txt")); err != nil { + t.Fatalf("uncommitted work lost: %v", err) + } +} + func TestManagerPruneSkipsLocked(t *testing.T) { repo := t.TempDir() gitInit(t, repo) From ac9b9cbd46df00605b77b8fa16054686e5b39eee Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:35:54 -0300 Subject: [PATCH 12/26] fix: enforce read-only reviewer tools --- docs/task4-verification.md | 7 ++++--- internal/ocxreviewer/reviewer.go | 7 ++++--- internal/ocxreviewer/reviewer_test.go | 11 +++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/task4-verification.md b/docs/task4-verification.md index 968f3f8..413109d 100644 --- a/docs/task4-verification.md +++ b/docs/task4-verification.md @@ -55,8 +55,9 @@ focused feedback; budgets bound retries; prose alone never completes work. intervention (used by later tasks). - `TestOpenCodeEvidenceGates` is deterministic because the gates grep for fixed markers the prompt demands, independent of model behavior. -- Reviewer sessions are read-only (write tools removed, bash kept for - tests/diffs), poll the transcript to idle, and parse the verdict from the - last assistant message. The verdict format is fixed in the prompt: +- Reviewer sessions are read-only (shell and write tools removed), review + recorded diffs and command results from the evidence prompt, poll the + transcript to idle, and parse the verdict from the last assistant message. + The verdict format is fixed in the prompt: `APPROVED`/`NOT_APPROVED` plus a `Note:` line; anything else fails the gate with a parse error. diff --git a/internal/ocxreviewer/reviewer.go b/internal/ocxreviewer/reviewer.go index bbf8c50..d581e0a 100644 --- a/internal/ocxreviewer/reviewer.go +++ b/internal/ocxreviewer/reviewer.go @@ -48,10 +48,11 @@ func (o Options) poll() time.Duration { return o.PollInterval } -// reviewTools keeps reviewer sessions read-only: editing and other write -// paths are removed while bash and read stay available so the reviewer can -// inspect diffs and run tests, mirroring the corral-reviewer agent. +// reviewTools keeps reviewer sessions read-only. The reviewer evaluates the +// recorded diff, transcript, and check results included in its prompt; shell +// access is disabled because it can mutate the attempt worktree. var reviewTools = map[string]bool{ + "bash": false, "edit": false, "write": false, "apply_patch": false, diff --git a/internal/ocxreviewer/reviewer_test.go b/internal/ocxreviewer/reviewer_test.go index cf0beb6..8eefde8 100644 --- a/internal/ocxreviewer/reviewer_test.go +++ b/internal/ocxreviewer/reviewer_test.go @@ -241,6 +241,17 @@ func TestPromptForIncludesEvidence(t *testing.T) { } } +func TestReviewToolsAreReadOnly(t *testing.T) { + for _, name := range []string{"bash", "edit", "write", "apply_patch"} { + enabled, explicit := reviewTools[name] + if !explicit { + t.Errorf("%s tool has no explicit deny rule", name) + } else if enabled { + t.Errorf("%s tool enabled in read-only reviewer", name) + } + } +} + func TestOpenCodeReviewerLive(t *testing.T) { livetest.SkipIfDisabled(t) if _, err := exec.LookPath("opencode"); err != nil { From 33d135ec1b0ab3c8c56c6593478561d5521e4ff0 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:38:17 -0300 Subject: [PATCH 13/26] fix: keep pre-authorized gates explicit --- README.md | 3 ++- docs/task6-plugin.md | 4 ++-- internal/daemon/daemon_test.go | 33 ++++++++++++++++++++-------- internal/sched/gates_test.go | 20 +++++++++-------- internal/sched/sched.go | 39 ++++++---------------------------- 5 files changed, 46 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 3c80416..c5ef50c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ Inside OpenCode: 3. Switch to `corral-orchestrator` and ask it to start that graph. 4. Follow progress with `corral_status` / `corral_watch`; approve, reject, retry, cancel, or steer nodes when needed. `corral_start` accepts an - optional `autoApproveGates` flag to skip operator approval on gates. + optional `autoApproveGates` flag pre-authorizes the orchestrator to call + the normal gate approval endpoint without waiting for the operator. Or follow the same run from the terminal: diff --git a/docs/task6-plugin.md b/docs/task6-plugin.md index 22b63f1..83f9f79 100644 --- a/docs/task6-plugin.md +++ b/docs/task6-plugin.md @@ -15,8 +15,8 @@ flow is verified end-to-end against a real OpenCode server. - `POST /api/runs` — start a run from a graph; run loops live on the daemon context (not request context — bug found and fixed) and persist via SQLite. Accepts `autoApproveGates` (stored on the run and - exposed by `GET /api/runs/{id}`); gates then pass without operator - approval. + exposed by `GET /api/runs/{id}`); gates remain explicit, and the flag + authorizes the orchestrator to call the normal approval endpoint. - `GET /api/runs`, `GET /api/runs/{id}` — follow execution (states, attempts, event log). - `GET /api/runs/{id}/watch` — Server-Sent Events stream of run deltas diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index d12cb2f..30bb664 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -299,10 +299,10 @@ func (f *fakePlanner) Plan(_ context.Context, _ string) (*graph.Graph, error) { var _ = daemon.RoleOperator -// TestAutoApproveGatesThroughAPI creates a run with autoApproveGates and -// verifies the flag is stored, exposed by GET /api/runs/{id}, and that the -// gate completes without any operator approval. -func TestAutoApproveGatesThroughAPI(t *testing.T) { +// TestPreAuthorizedGateThroughAPI verifies that autoApproveGates is stored +// and exposed as authorization metadata while the gate still waits for an +// explicit orchestrator/operator API action. +func TestPreAuthorizedGateThroughAPI(t *testing.T) { a, _, _, drv := setupDaemon(t, "") drv.SetScript("w1", sched.Script{Delay: 100 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) g := &graph.Graph{Nodes: []*graph.Node{ @@ -321,12 +321,27 @@ func TestAutoApproveGatesThroughAPI(t *testing.T) { if code != http.StatusOK || !strings.Contains(body, `"autoApproveGates":true`) { t.Fatalf("autoApproveGates not exposed: %d %s", code, body) } - // No operator approval needed: the gate completes on its own and the - // run settles without any approve call. + // The flag authorizes the orchestrator to act; scheduler still exposes + // the gate instead of silently bypassing it. + a.waitState(t, "", created.RunID, "gate", graph.StateRunning, 30*time.Second) + snap := a.watchUntil(t, created.RunID, "", func(m map[string]any) bool { + gates, _ := m["gatesAwaitingApproval"].([]any) + return len(gates) == 1 && gates[0] == "gate" + }) + if aa, _ := snap["autoApproveGates"].(bool); !aa { + t.Fatal("watch snapshot lost pre-authorization flag") + } + code, body = a.do("orchestrator", http.MethodPost, "/api/runs/"+created.RunID+"/approve", map[string]any{"nodeID": "gate"}) + if code != http.StatusOK { + t.Fatalf("orchestrator approve: %d %s", code, body) + } a.waitState(t, "", created.RunID, "gate", graph.StateDone, 30*time.Second) - code, body = a.do("operator", http.MethodGet, "/api/runs/"+created.RunID, nil) - if code != http.StatusOK || !strings.Contains(body, `"done":true`) { - t.Fatalf("run not done: %d %s", code, body) + snap = a.watchUntil(t, created.RunID, "", func(m map[string]any) bool { + done, _ := m["done"].(bool) + return done + }) + if snap["status"] != "completed" { + t.Fatalf("run status = %v, want completed", snap["status"]) } } diff --git a/internal/sched/gates_test.go b/internal/sched/gates_test.go index d97fea8..528105e 100644 --- a/internal/sched/gates_test.go +++ b/internal/sched/gates_test.go @@ -134,7 +134,7 @@ func TestTokenBudgetBoundsRetries(t *testing.T) { } } -func TestAutoApproveGatesSkipsOperator(t *testing.T) { +func TestPreAuthorizedGateStillRequiresApprovalAction(t *testing.T) { st := newStore(t) clk := fakeClock() drv := sched.NewFakeDriver(clk, scriptsFor("w")) @@ -149,16 +149,18 @@ func TestAutoApproveGatesSkipsOperator(t *testing.T) { t.Fatal(err) } drive(t, h, clk, 100) - if !h.Done() { - t.Fatal("auto-approve run did not settle") + if h.Done() { + t.Fatal("pre-authorized run bypassed its human gate") + } + if st2, _ := h.State("gate"); st2 != graph.StateRunning { + t.Fatalf("gate state = %s, want running until orchestrator approval", st2) } - // The gate must have passed without an operator action. - if st2, _ := h.State("gate"); st2 != graph.StateDone { - t.Fatalf("gate state = %s, want done (auto-approved)", st2) + if err := h.ApproveNode(context.Background(), "gate"); err != nil { + t.Fatal(err) } - atts, _ := st.Attempts(context.Background(), "run-autoapprove", "gate") - if len(atts) != 1 || atts[0].Status != "done" { - t.Fatalf("gate attempts = %+v, want a single done attempt", atts) + drive(t, h, clk, 100) + if !h.Done() { + t.Fatal("pre-authorized run did not settle after approval action") } r, err := st.Run(context.Background(), "run-autoapprove") if err != nil { diff --git a/internal/sched/sched.go b/internal/sched/sched.go index c251d1c..09fee54 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -131,8 +131,6 @@ type RunHandle struct { done bool stepCount int64 started time.Time - - autoApproveGates bool } // CreateOptions carries per-run creation policies (extended without @@ -158,7 +156,7 @@ func (s *Scheduler) Create(ctx context.Context, runID string, g *graph.Graph, op if err := s.store.CreateRun(ctx, runID, g, o.AutoApproveGates, now); err != nil { return nil, err } - return s.newHandle(ctx, runID, g, now, o.AutoApproveGates) + return s.newHandle(ctx, runID, g, now) } // Load resumes a persisted run: events are replayed into a fresh tracker @@ -239,8 +237,6 @@ func (s *Scheduler) Load(ctx context.Context, runID string) (*RunHandle, error) results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, - - autoApproveGates: r.AutoApproveGates, } for _, ev := range events { if ev.Type == store.EventRetry { @@ -271,7 +267,7 @@ func retryReadyAt(events []store.Event, nodeID graph.NodeID) (time.Time, bool) { return time.Time{}, false } -func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time, autoApproveGates bool) (*RunHandle, error) { +func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, now time.Time) (*RunHandle, error) { tr, err := graph.NewTracker(g) if err != nil { return nil, err @@ -289,8 +285,6 @@ func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, - - autoApproveGates: autoApproveGates, }, nil } @@ -807,9 +801,9 @@ func (h *RunHandle) startMerge(ctx context.Context, n *graph.Node, attemptID str return h.completeInline(ctx, n, attemptID, no, now, exit, stdout, stderr, merged) } -// startGate parks a human gate node in running until an operator approves -// or rejects it. No driver session is involved. When the run was created -// with autoApproveGates the gate is approved immediately instead. +// startGate parks a human gate node in running until an operator or an +// explicitly pre-authorized orchestrator approves or rejects it. No driver +// session is involved; pre-authorization is policy metadata, not a bypass. func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID string, no int, now time.Time) error { sess := &gateSession{id: "gate:" + string(n.ID)} started := now.UnixMilli() @@ -828,27 +822,8 @@ func (h *RunHandle) startGate(ctx context.Context, n *graph.Node, attemptID stri }); err != nil { return err } - if err := h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, - `{"phase":"start","sessionID":"`+sess.ID()+`"}`); err != nil { - return err - } - if !h.autoApproveGates { - return nil - } - // Auto-approve: no operator round-trip; the gate passes through the - // evidence machine (running → verifying → done) like a manual approve. - delete(h.sessions, n.ID) - finished := now.UnixMilli() - if err := h.transit(ctx, n.ID, graph.StateRunning, graph.StateVerifying, ""); err != nil { - return err - } - if err := h.transit(ctx, n.ID, graph.StateVerifying, graph.StateDone, ""); err != nil { - return err - } - return h.s.store.RecordAttempt(ctx, store.Attempt{ - ID: attemptID, RunID: h.runID, NodeID: string(n.ID), No: no, - Status: "done", SessionID: sess.ID(), StartedAt: &started, FinishedAt: &finished, - }) + return h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, + `{"phase":"start","sessionID":"`+sess.ID()+`"}`) } // completeInline registers the session, transitions to running, records From 4fca6604951696fd6bdcac19726715e3e5338300 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:31:18 -0300 Subject: [PATCH 14/26] feat: stream durable run events over SSE Add cursor-based replay plus non-blocking live delivery so clients can reconnect without losing committed events. Close slow subscribers safely, retain bearer auth, and document the additive endpoint. --- cmd/corral/main.go | 1 + internal/daemon/broker.go | 110 ++++++++++ internal/daemon/broker_test.go | 208 +++++++++++++++++++ internal/daemon/daemon.go | 42 +++- internal/daemon/daemon_test.go | 15 ++ internal/daemon/e2e_test.go | 1 + internal/daemon/events.go | 193 ++++++++++++++++++ internal/daemon/events_test.go | 325 ++++++++++++++++++++++++++++++ internal/daemon/hardening_test.go | 4 +- internal/daemon/openapi.go | 19 +- internal/sched/sched.go | 9 +- internal/store/store.go | 143 +++++++++++-- internal/store/store_test.go | 47 +++++ internal/tui/client_test.go | 1 + 14 files changed, 1088 insertions(+), 30 deletions(-) create mode 100644 internal/daemon/broker.go create mode 100644 internal/daemon/broker_test.go create mode 100644 internal/daemon/events.go create mode 100644 internal/daemon/events_test.go diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 4cb0351..1f4d3e4 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -184,6 +184,7 @@ func daemonCmd(port int, apiKey string) error { log.Printf("run safeguards: breaker %d failures per %s; run budget %d tokens / $%.2f", opts.BreakerMaxFailures, opts.BreakerWindow, opts.RunMaxTokens, opts.RunMaxCost) d := daemon.New(st, s, daemon.NewOpenCodePlanner(oc, "", planTimeout()), dir, apiKey) + defer d.Close() if err := d.Resume(ctx); err != nil { log.Printf("resume: %v", err) } diff --git a/internal/daemon/broker.go b/internal/daemon/broker.go new file mode 100644 index 0000000..d63d99c --- /dev/null +++ b/internal/daemon/broker.go @@ -0,0 +1,110 @@ +package daemon + +import ( + "sync" + + "corral/internal/store" +) + +// broker is the in-daemon live-event hub. The store notifies it of every +// committed event; it fans those out to per-run subscribers (SSE clients). +// +// Subscribers are removed on disconnect via the returned unsubscribe func. +// A subscriber whose buffer overflows is dropped so the scheduler never +// blocks on a slow consumer; the client reconnects with an after cursor and +// the store replays the gap. +type broker struct { + mu sync.Mutex + subs map[string]map[*subscriber]struct{} + closed bool +} + +type subscriber struct { + runID string + ch chan store.Event +} + +const subscriberBuffer = 256 + +func newBroker() *broker { + return &broker{subs: map[string]map[*subscriber]struct{}{}} +} + +// Subscribe registers a subscriber for runID. Events for that run are +// delivered on the returned channel; the caller must call the returned +// func when done (e.g. on request context cancellation). +func (b *broker) Subscribe(runID string) (<-chan store.Event, func()) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + ch := make(chan store.Event) + close(ch) + return ch, func() {} + } + if b.subs[runID] == nil { + b.subs[runID] = map[*subscriber]struct{}{} + } + sub := &subscriber{runID: runID, ch: make(chan store.Event, subscriberBuffer)} + b.subs[runID][sub] = struct{}{} + return sub.ch, func() { b.unsubscribe(sub) } +} + +func (b *broker) unsubscribe(sub *subscriber) { + b.mu.Lock() + defer b.mu.Unlock() + subs := b.subs[sub.runID] + if _, ok := subs[sub]; ok { + delete(subs, sub) + close(sub.ch) + if len(subs) == 0 { + delete(b.subs, sub.runID) + } + } +} + +// Publish delivers an event to every subscriber of its run. It never +// blocks: slow consumers are dropped rather than stalling the writer. +func (b *broker) Publish(ev store.Event) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return + } + subs := b.subs[ev.RunID] + for sub := range subs { + select { + case sub.ch <- ev: + default: + delete(subs, sub) + close(sub.ch) + } + } + if len(subs) == 0 { + delete(b.subs, ev.RunID) + } +} + +// Close shuts the broker down, closing every subscriber channel. Publish +// after Close is a no-op. +func (b *broker) Close() { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return + } + b.closed = true + for _, subs := range b.subs { + for sub := range subs { + close(sub.ch) + } + } + b.subs = map[string]map[*subscriber]struct{}{} +} + +// SubscriberCount reports how many subscribers a run currently has +// (used by tests to assert cleanup on disconnect). +func (b *broker) SubscriberCount(runID string) int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.subs[runID]) +} diff --git a/internal/daemon/broker_test.go b/internal/daemon/broker_test.go new file mode 100644 index 0000000..4d93918 --- /dev/null +++ b/internal/daemon/broker_test.go @@ -0,0 +1,208 @@ +package daemon + +import ( + "context" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "testing" + "time" + + "corral/internal/clock" + "corral/internal/graph" + "corral/internal/sched" + "corral/internal/store" + "corral/internal/verify" + "corral/internal/worktree" +) + +func TestBrokerRoutesByRun(t *testing.T) { + b := newBroker() + ch1, unsub1 := b.Subscribe("r1") + defer unsub1() + ch1b, unsub1b := b.Subscribe("r1") + defer unsub1b() + ch2, unsub2 := b.Subscribe("r2") + defer unsub2() + + b.Publish(store.Event{Seq: 1, RunID: "r1", Type: store.EventRun}) + + for _, ch := range []<-chan store.Event{ch1, ch1b} { + select { + case ev := <-ch: + if ev.Seq != 1 || ev.RunID != "r1" { + t.Fatalf("r1 subscriber got %+v", ev) + } + default: + t.Fatal("r1 subscriber missed event") + } + } + select { + case ev := <-ch2: + t.Fatalf("r2 subscriber got r1 event %+v", ev) + default: + } +} + +func TestBrokerUnsubscribeCleansUp(t *testing.T) { + b := newBroker() + _, unsub := b.Subscribe("r1") + if b.SubscriberCount("r1") != 1 { + t.Fatalf("count = %d, want 1", b.SubscriberCount("r1")) + } + unsub() + if b.SubscriberCount("r1") != 0 { + t.Fatalf("count after unsub = %d, want 0", b.SubscriberCount("r1")) + } + b.Publish(store.Event{Seq: 1, RunID: "r1"}) // must not panic or deliver +} + +func TestBrokerDropsSlowSubscriber(t *testing.T) { + b := newBroker() + ch, _ := b.Subscribe("r1") + for i := 0; i < subscriberBuffer; i++ { + b.Publish(store.Event{Seq: int64(i + 1), RunID: "r1"}) + } + b.Publish(store.Event{Seq: 1000, RunID: "r1"}) + if n := b.SubscriberCount("r1"); n != 0 { + t.Fatalf("slow subscriber not dropped: count = %d", n) + } + deadline := time.After(time.Second) + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-deadline: + t.Fatal("dropped subscriber channel was not closed") + } + } +} + +func TestBrokerCloseClosesSubscribers(t *testing.T) { + b := newBroker() + ch, _ := b.Subscribe("r1") + b.Close() + if _, ok := <-ch; ok { + t.Fatal("subscriber channel not closed after Close") + } + b.Publish(store.Event{Seq: 1, RunID: "r1"}) // no-op after close + ch2, _ := b.Subscribe("r1") + if _, ok := <-ch2; ok { + t.Fatal("Subscribe after Close returned an open channel") + } +} + +func TestBrokerPublishUnsubscribeRace(t *testing.T) { + b := newBroker() + for i := 0; i < 500; i++ { + _, unsubscribe := b.Subscribe("r1") + done := make(chan struct{}) + go func(seq int64) { + defer close(done) + b.Publish(store.Event{Seq: seq, RunID: "r1"}) + }(int64(i + 1)) + unsubscribe() + <-done + } + if got := b.SubscriberCount("r1"); got != 0 { + t.Fatalf("subscriber count = %d, want 0", got) + } +} + +func TestClosingOneDaemonDoesNotDetachAnother(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "d.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + clk := clock.Real{} + s := sched.New(st, sched.NewFakeDriver(clk, nil), &sched.EngineVerifier{Eng: verify.New(t.TempDir())}, clk, sched.Options{}) + one := New(st, s, nil, t.TempDir(), "") + two := New(st, s, nil, t.TempDir(), "") + t.Cleanup(two.Close) + one.Close() + + events, unsubscribe := two.broker.Subscribe("r1") + defer unsubscribe() + if err := st.CreateRun(context.Background(), "r1", &graph.Graph{Nodes: []*graph.Node{{ + ID: "w1", Type: graph.NodeAgent, Objective: "o", AcceptanceCriteria: []string{"c"}, Priority: graph.PriorityNormal, + }}}, false, time.Now()); err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event.Seq != 1 || event.RunID != "r1" { + t.Fatalf("second daemon got %+v", event) + } + case <-time.After(time.Second): + t.Fatal("closing first daemon detached second daemon") + } +} + +func TestSSESubscriberCleanupOnDisconnect(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "d.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + repo := t.TempDir() + for _, args := range [][]string{ + {"init", "-q", "-b", "main"}, + {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + clk := clock.Real{} + drv := sched.NewFakeDriver(clk, nil) + eng := verify.New(repo) + eng.Runner = verify.ExecRunner{} + s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clk, sched.Options{ + Concurrency: 4, Worktrees: worktree.NewManager(repo), + }) + d := New(st, s, nil, repo, "") + t.Cleanup(d.Close) + srv := httptest.NewServer(d.Handler()) + t.Cleanup(srv.Close) + + ctx := context.Background() + h, err := s.Create(ctx, "run_cleanup", &graph.Graph{Nodes: []*graph.Node{ + {ID: "w1", Type: graph.NodeAgent, Role: "worker", Objective: "o", + AcceptanceCriteria: []string{"c"}, Priority: graph.PriorityNormal, + Meta: map[string]string{"cwd": repo}, + Verification: &graph.Verification{Kind: "command", Command: []string{"true"}}}, + }}) + if err != nil { + t.Fatal(err) + } + go h.Run(ctx, 50*time.Millisecond) + + reqCtx, cancel := context.WithCancel(context.Background()) + req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, srv.URL+"/api/runs/run_cleanup/events", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return d.broker.SubscriberCount("run_cleanup") == 1 }, "subscriber registered") + cancel() + waitFor(t, func() bool { return d.broker.SubscriberCount("run_cleanup") == 0 }, "subscriber cleaned up on disconnect") + resp.Body.Close() +} + +func waitFor(t *testing.T, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", msg) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 583111a..bd3bd4b 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -54,8 +54,14 @@ type Daemon struct { apiKey string ctx context.Context - mu sync.Mutex - runs map[string]*sched.RunHandle + mu sync.Mutex + runs map[string]*sched.RunHandle + broker *broker + eventHeartbeat time.Duration + eventHeartbeatMu sync.RWMutex + eventUnsubscribe func() + eventPumpDone chan struct{} + closeOnce sync.Once } // Dir returns the project directory the daemon manages. @@ -65,11 +71,36 @@ func (d *Daemon) Dir() string { return d.dir } func (d *Daemon) SetPlanner(p Planner) { d.plan = p } func New(st *store.Store, s *sched.Scheduler, plan Planner, dir, apiKey string) *Daemon { - return &Daemon{ + events, unsubscribe := st.SubscribeEvents() + d := &Daemon{ st: st, sched: s, plan: plan, dir: dir, apiKey: apiKey, - ctx: context.Background(), - runs: map[string]*sched.RunHandle{}, + ctx: context.Background(), + runs: map[string]*sched.RunHandle{}, + broker: newBroker(), + eventHeartbeat: defaultEventHeartbeat, + eventUnsubscribe: unsubscribe, + eventPumpDone: make(chan struct{}), + } + go d.forwardEvents(events) + return d +} + +func (d *Daemon) forwardEvents(events <-chan store.Event) { + defer close(d.eventPumpDone) + for ev := range events { + d.broker.Publish(ev) } + d.broker.Close() +} + +// Close detaches the daemon from the store and closes live event streams. +// Other daemons subscribed to the same store are unaffected. +func (d *Daemon) Close() { + d.closeOnce.Do(func() { + d.eventUnsubscribe() + d.broker.Close() + <-d.eventPumpDone + }) } // SetContext replaces the daemon's background context (its lifetime). @@ -115,6 +146,7 @@ func (d *Daemon) Handler() http.Handler { mux.HandleFunc("POST /api/runs/{id}/steer", d.role(RoleOperator, RoleOrchestrator)(d.handleSteer)) mux.HandleFunc("POST /api/runs/{id}/permission", d.role(RoleOperator, RoleOrchestrator)(d.handlePermission)) mux.HandleFunc("GET /api/runs/{id}/export", d.handleExport) + mux.HandleFunc("GET /api/runs/{id}/events", d.handleEvents) mux.HandleFunc("GET /doc", d.handleOpenAPI) return d.auth(mux) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 30bb664..4d8874d 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -78,6 +78,7 @@ func setupDaemon(t *testing.T, apiKey string) (*api, *daemon.Daemon, *store.Stor Concurrency: 4, Worktrees: wtm, }) d := daemon.New(st, s, nil, repo, apiKey) + t.Cleanup(d.Close) srv := httptest.NewServer(d.Handler()) t.Cleanup(srv.Close) return &api{t: t, cli: srv.Client(), base: srv.URL}, d, st, drv @@ -260,6 +261,20 @@ func TestCancelAndRetryThroughAPI(t *testing.T) { if code != http.StatusOK { t.Fatalf("steer: %d %s", code, body) } + events, err := st.Events(context.Background(), created.RunID) + if err != nil { + t.Fatal(err) + } + var steer *store.Event + for i := range events { + if events[i].Type == store.EventSteer { + steer = &events[i] + break + } + } + if steer == nil || steer.NodeID != "w1" || !strings.Contains(string(steer.Payload), "wrap up") { + t.Fatalf("steer event missing or malformed: %+v", steer) + } code, body = a.do("operator", http.MethodPost, "/api/runs/"+created.RunID+"/cancel", map[string]any{"nodeID": "w1"}) if code != http.StatusOK { t.Fatalf("cancel: %d %s", code, body) diff --git a/internal/daemon/e2e_test.go b/internal/daemon/e2e_test.go index 44efa27..295af51 100644 --- a/internal/daemon/e2e_test.go +++ b/internal/daemon/e2e_test.go @@ -71,6 +71,7 @@ func TestDaemonEndToEndRealOpenCode(t *testing.T) { Concurrency: 2, Worktrees: wtm, }) d := daemon.New(st, s, nil, proj, "") + t.Cleanup(d.Close) srvHTTP := httptest.NewServer(d.Handler()) t.Cleanup(srvHTTP.Close) api := &api{t: t, cli: srvHTTP.Client(), base: srvHTTP.URL} diff --git a/internal/daemon/events.go b/internal/daemon/events.go new file mode 100644 index 0000000..f36387f --- /dev/null +++ b/internal/daemon/events.go @@ -0,0 +1,193 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "corral/internal/store" +) + +// defaultEventHeartbeat is the idle keep-alive interval for the SSE stream. +const defaultEventHeartbeat = 15 * time.Second + +// SetEventHeartbeat overrides the SSE heartbeat interval (default 15s). +func (d *Daemon) SetEventHeartbeat(interval time.Duration) { + if interval <= 0 { + interval = defaultEventHeartbeat + } + d.eventHeartbeatMu.Lock() + d.eventHeartbeat = interval + d.eventHeartbeatMu.Unlock() +} + +func (d *Daemon) eventHeartbeatInterval() time.Duration { + d.eventHeartbeatMu.RLock() + defer d.eventHeartbeatMu.RUnlock() + return d.eventHeartbeat +} + +// handleEvents streams the run's event log as server-sent events. The +// ?after= query cursor selects only events with seq greater than the +// cursor; persisted events are replayed first and live transitions (and +// heartbeats) follow on the broker. A terminal run event ends the stream after +// delivery; reconnecting with its sequence cursor is safe. +func (d *Daemon) handleEvents(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + runID := r.PathValue("id") + after, err := parseEventCursor(r.URL.Query().Get("after")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + run, err := d.st.Run(ctx, runID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Subscribe before reading the durable snapshot. An event committed during + // the read queues a notification, and every notification is reconciled from + // the store by sequence, so overlap is deduplicated and gaps are impossible. + ch, unsubscribe := d.broker.Subscribe(runID) + defer unsubscribe() + replay, err := d.st.EventsAfter(ctx, runID, after) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + frames, err := encodeEventFrames(replay, after) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + last := after + terminal := run.Status == "completed" || run.Status == "canceled" + for _, frame := range frames { + if _, err := w.Write(frame.data); err != nil { + return + } + fl.Flush() + last = frame.event.Seq + terminal = terminal || terminalRunEvent(frame.event) + } + if len(frames) == 0 { + fl.Flush() + } + if terminal { + return + } + + hb := time.NewTicker(d.eventHeartbeatInterval()) + defer hb.Stop() + + for { + select { + case _, ok := <-ch: + if !ok { + return + } + // The broker is a wake-up path, not the source of truth. Reading the + // durable log here preserves order even when concurrent commits notify + // out of order. + events, err := d.st.EventsAfter(ctx, runID, last) + if err != nil { + return // headers are committed; reconnect replays from last id + } + for _, ev := range events { + if ev.Seq <= last { + continue + } + if err := writeEventSSE(w, fl, ev); err != nil { + return + } + last = ev.Seq + if terminalRunEvent(ev) { + return + } + } + case <-hb.C: + if _, err := io.WriteString(w, ": ping\n\n"); err != nil { + return + } + fl.Flush() + case <-ctx.Done(): + return + } + } +} + +func parseEventCursor(raw string) (int64, error) { + if raw == "" { + return 0, nil + } + after, err := strconv.ParseInt(raw, 10, 64) + if err != nil || after < 0 { + return 0, fmt.Errorf("after must be a non-negative integer") + } + return after, nil +} + +type eventFrame struct { + event store.Event + data []byte +} + +func encodeEventFrames(events []store.Event, after int64) ([]eventFrame, error) { + frames := make([]eventFrame, 0, len(events)) + for _, ev := range events { + if ev.Seq <= after { + continue + } + var buf bytes.Buffer + if err := writeEventSSE(&buf, nil, ev); err != nil { + return nil, err + } + frames = append(frames, eventFrame{event: ev, data: buf.Bytes()}) + } + return frames, nil +} + +func terminalRunEvent(ev store.Event) bool { + if ev.Type != store.EventRun { + return false + } + var payload struct { + Status string `json:"status"` + } + if json.Unmarshal(ev.Payload, &payload) != nil { + return false + } + return payload.Status == "completed" || payload.Status == "canceled" +} + +// writeEventSSE frames one event as an SSE record with a resume id. +func writeEventSSE(w io.Writer, fl http.Flusher, ev store.Event) error { + b, err := json.Marshal(ev) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, b); err != nil { + return err + } + if fl != nil { + fl.Flush() + } + return nil +} diff --git a/internal/daemon/events_test.go b/internal/daemon/events_test.go new file mode 100644 index 0000000..f4db31c --- /dev/null +++ b/internal/daemon/events_test.go @@ -0,0 +1,325 @@ +package daemon_test + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "corral/internal/graph" + "corral/internal/sched" + "corral/internal/store" +) + +// readSSE reads one server-sent event record from r, returning its id and +// data, or the heartbeat flag when the line is a comment. +func readSSE(t *testing.T, r *bufio.Reader) (id int64, data string, ping bool) { + t.Helper() + var recID string + var dataLines []string + for { + line, err := r.ReadString('\n') + if err != nil { + t.Fatalf("read sse: %v", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + if recID != "" || len(dataLines) > 0 { + n, _ := strconv.ParseInt(recID, 10, 64) + return n, strings.Join(dataLines, "\n"), false + } + continue + } + switch { + case strings.HasPrefix(line, ": "): + return 0, "", true + case strings.HasPrefix(line, "id: "): + recID = strings.TrimPrefix(line, "id: ") + case strings.HasPrefix(line, "data: "): + dataLines = append(dataLines, strings.TrimPrefix(line, "data: ")) + } + } +} + +func TestEventsSSEStreamsFullRun(t *testing.T) { + a, _, _, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 200 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + + resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/events") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("content-type = %q, want text/event-stream", ct) + } + + r := bufio.NewReader(resp.Body) + var seqs []int64 + var types []string + toStates := map[string]bool{} + deadline := time.Now().Add(20 * time.Second) + completed := false + for time.Now().Before(deadline) { + id, data, ping := readSSE(t, r) + if ping { + continue + } + var ev store.Event + if err := json.Unmarshal([]byte(data), &ev); err != nil { + t.Fatalf("bad event json: %v: %s", err, data) + } + if ev.Seq != id { + t.Fatalf("id %d does not match event seq %d", id, ev.Seq) + } + seqs = append(seqs, ev.Seq) + types = append(types, string(ev.Type)) + if ev.To != "" { + toStates[string(ev.To)] = true + } + if ev.Type == store.EventRun && strings.Contains(data, "completed") { + completed = true + break + } + } + if !completed { + t.Fatalf("run completion never streamed; types = %v", types) + } + for i, s := range seqs { + if s != int64(i+1) { + t.Fatalf("seq[%d] = %d, want %d (contiguous from 1)", i, s, i+1) + } + } + for _, want := range []string{"ready", "leased", "running", "verifying", "done"} { + if !toStates[want] { + t.Fatalf("missing %s transition in stream (got %v)", want, toStates) + } + } + if types[0] != "run" { + t.Fatalf("first event type = %q, want run", types[0]) + } +} + +func TestEventsSSEAfterCursor(t *testing.T) { + a, _, _, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 150 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + + // Replay-only: connect with a cursor and only the events past it must + // be emitted. + resp, err := http.Get(fmt.Sprintf("%s/api/runs/%s/events?after=5", a.base, created.RunID)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + r := bufio.NewReader(resp.Body) + first := true + var minSeq int64 + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + _, data, ping := readSSE(t, r) + if ping { + continue + } + var ev store.Event + if err := json.Unmarshal([]byte(data), &ev); err != nil { + t.Fatalf("bad event: %v", err) + } + if first { + minSeq = ev.Seq + first = false + } + if ev.Seq <= 5 { + t.Fatalf("event seq %d emitted despite after=5", ev.Seq) + } + if ev.Type == store.EventRun && strings.Contains(data, "completed") { + break + } + } + if first { + t.Fatal("no events received") + } + if minSeq != 6 { + t.Fatalf("first event seq = %d, want 6", minSeq) + } +} + +func TestEventsSSEHeartbeats(t *testing.T) { + a, d, st, _ := setupDaemon(t, "") + d.SetEventHeartbeat(100 * time.Millisecond) + const runID = "run_heartbeat" + if err := st.CreateRun(context.Background(), runID, &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}}, false, time.Now()); err != nil { + t.Fatal(err) + } + + resp, err := http.Get(a.base + "/api/runs/" + runID + "/events?after=1") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + r := bufio.NewReader(resp.Body) + pings := 0 + deadline := time.Now().Add(800 * time.Millisecond) + for time.Now().Before(deadline) { + _, _, ping := readSSE(t, r) + if ping { + pings++ + } + } + if pings == 0 { + t.Fatal("no heartbeats received") + } +} + +func TestEventsSSEReconnectReplaysMissedEvents(t *testing.T) { + a, _, st, _ := setupDaemon(t, "") + ctx := context.Background() + const runID = "run_reconnect" + if err := st.CreateRun(ctx, runID, &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}}, false, time.Now()); err != nil { + t.Fatal(err) + } + + firstCtx, cancel := context.WithCancel(ctx) + request, _ := http.NewRequestWithContext(firstCtx, http.MethodGet, a.base+"/api/runs/"+runID+"/events", nil) + response, err := a.cli.Do(request) + if err != nil { + t.Fatal(err) + } + id, _, ping := readSSE(t, bufio.NewReader(response.Body)) + if ping || id != 1 { + t.Fatalf("first stream record: id=%d ping=%v, want event 1", id, ping) + } + cancel() + response.Body.Close() + + for i := 2; i <= 3; i++ { + if _, err := st.AppendEvent(ctx, runID, "w1", store.EventGraph, "", "", "", fmt.Sprintf(`{"version":%d}`, i), time.Now()); err != nil { + t.Fatal(err) + } + } + + response, err = http.Get(a.base + "/api/runs/" + runID + "/events?after=1") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + reader := bufio.NewReader(response.Body) + for want := int64(2); want <= 3; want++ { + id, data, ping := readSSE(t, reader) + if ping || id != want { + t.Fatalf("replayed record: id=%d ping=%v, want event %d", id, ping, want) + } + var event store.Event + if err := json.Unmarshal([]byte(data), &event); err != nil { + t.Fatal(err) + } + if event.Seq != want || event.RunID != runID { + t.Fatalf("replayed event = %+v", event) + } + } +} + +func TestEventsSSEUsesBearerAuthLikeOtherReadEndpoints(t *testing.T) { + a, _, st, _ := setupDaemon(t, "sekret") + const runID = "run_auth" + if err := st.CreateRun(context.Background(), runID, &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}}, false, time.Now()); err != nil { + t.Fatal(err) + } + + request, _ := http.NewRequest(http.MethodGet, a.base+"/api/runs/"+runID+"/events", nil) + response, err := a.cli.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("without bearer: %d, want 401", response.StatusCode) + } + + request, _ = http.NewRequest(http.MethodGet, a.base+"/api/runs/"+runID+"/events", nil) + request.Header.Set("Authorization", "Bearer sekret") + response, err = a.cli.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("with bearer: %d, want 200", response.StatusCode) + } + id, _, ping := readSSE(t, bufio.NewReader(response.Body)) + if ping || id != 1 { + t.Fatalf("authorized record: id=%d ping=%v, want event 1", id, ping) + } +} + +func TestEventsSSERejectsInvalidCursor(t *testing.T) { + a, _, st, _ := setupDaemon(t, "") + const runID = "run_cursor" + if err := st.CreateRun(context.Background(), runID, &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}}, false, time.Now()); err != nil { + t.Fatal(err) + } + for _, after := range []string{"-1", "abc"} { + code, body := a.do("operator", http.MethodGet, "/api/runs/"+runID+"/events?after="+after, nil) + if code != http.StatusBadRequest { + t.Fatalf("after=%q: %d %s, want 400", after, code, body) + } + } +} + +func TestEventsSSEUnknownRun(t *testing.T) { + a, _, _, _ := setupDaemon(t, "") + code, body := a.do("operator", http.MethodGet, "/api/runs/nope/events", nil) + if code != http.StatusNotFound { + t.Fatalf("unknown run: %d %s, want 404", code, body) + } +} + +func TestEventsSSEDoesNotAffectJSONEndpoints(t *testing.T) { + a, _, st, drv := setupDaemon(t, "") + drv.SetScript("w1", sched.Script{Delay: 150 * time.Millisecond, Write: map[string]string{"a.txt": "A1"}}) + g := &graph.Graph{Nodes: []*graph.Node{workerNode("w1", "a.txt", "A1")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + json.Unmarshal([]byte(body), &created) + + // Open (and hold) an SSE connection while the run executes; the JSON + // endpoints must keep serving shape-stable responses regardless. + resp, err := http.Get(a.base + "/api/runs/" + created.RunID + "/events") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // The run detail must still be served and shape-stable while the + // stream is open. + a.waitState(t, "", created.RunID, "w1", graph.StateDone, 20*time.Second) + code, body = a.do("operator", http.MethodGet, "/api/runs/"+created.RunID, nil) + if code != http.StatusOK || !strings.Contains(body, `"done":true`) { + t.Fatalf("run detail broken: %d %s", code, body) + } + if _, err := st.Run(context.Background(), created.RunID); err != nil { + t.Fatalf("store run read failed: %v", err) + } +} diff --git a/internal/daemon/hardening_test.go b/internal/daemon/hardening_test.go index 58b89f8..fe80c75 100644 --- a/internal/daemon/hardening_test.go +++ b/internal/daemon/hardening_test.go @@ -71,6 +71,7 @@ func TestOpenAPIContract(t *testing.T) { t.Cleanup(func() { st.Close() }) s := sched.New(st, sched.NewFakeDriver(clock.Real{}, nil), &sched.EngineVerifier{Eng: verify.New(t.TempDir())}, clock.Real{}, sched.Options{}) d := daemon.New(st, s, nil, t.TempDir(), "") + t.Cleanup(d.Close) srv := httptest.NewServer(d.Handler()) t.Cleanup(srv.Close) @@ -82,7 +83,7 @@ func TestOpenAPIContract(t *testing.T) { paths, _ := doc["paths"].(map[string]any) registered := []string{ "/api/health", "/api/plan", "/api/runs", "/api/runs/{id}", - "/api/runs/{id}/watch", + "/api/runs/{id}/watch", "/api/runs/{id}/events", "/api/runs/{id}/approve", "/api/runs/{id}/reject", "/api/runs/{id}/cancel", "/api/runs/{id}/retry", "/api/runs/{id}/steer", "/api/runs/{id}/permission", "/api/runs/{id}/export", "/doc", @@ -152,6 +153,7 @@ func TestAuditExport(t *testing.T) { eng := verify.New(workdir) s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{Concurrency: 2}) d := daemon.New(st, s, nil, workdir, "") + t.Cleanup(d.Close) ctx := context.Background() d.SetContext(ctx) srv := httptest.NewServer(d.Handler()) diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index 68046c4..36b4712 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -10,7 +10,7 @@ import ( // document and live responses against its schemas. const OpenAPI = `{ "openapi": "3.0.3", - "info": {"title": "corral daemon API", "version": "0.8.0"}, + "info": {"title": "corral daemon API", "version": "0.9.0"}, "paths": { "/api/health": { "get": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Health"}}}}}} @@ -35,6 +35,17 @@ const OpenAPI = `{ "/api/runs/{id}/steer": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/permission": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/export": {"get": {"responses": {"200": {"description": "audit export", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Export"}}}}}}}, + "/api/runs/{id}/events": { + "get": { + "parameters": [{"name": "after", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 0}, "description": "only emit durable events with seq greater than this cursor"}], + "responses": { + "200": {"description": "server-sent stream whose data field is a durable Event and whose id is Event.seq", "content": {"text/event-stream": {"schema": {"type": "string"}}}}, + "400": {"description": "invalid cursor"}, + "404": {"description": "run not found"}, + "500": {"description": "event replay unavailable"} + } + } + }, "/doc": {"get": {"responses": {"200": {"description": "openapi document"}}}} }, "components": { @@ -96,11 +107,11 @@ const OpenAPI = `{ } }, "Event": { - "type": "object", "required": ["seq", "type"], + "type": "object", "required": ["seq", "runID", "type", "createdAt"], "properties": { - "seq": {"type": "integer"}, "nodeID": {"type": "string"}, + "seq": {"type": "integer"}, "runID": {"type": "string"}, "nodeID": {"type": "string"}, "type": {"type": "string"}, "from": {"type": "string"}, "to": {"type": "string"}, - "attemptID": {"type": "string"}, "createdAt": {"type": "integer"} + "attemptID": {"type": "string"}, "payload": {}, "createdAt": {"type": "integer"} } }, "Artifact": { diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 09fee54..1c6cd5c 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -960,6 +960,8 @@ func (h *RunHandle) PermissionSession(_ context.Context, id graph.NodeID) (adapt } // Steer sends a message to the in-flight attempt of a node (agent steer). +// The steering action is recorded in the event log so it participates in +// the monotonic per-run sequence streamed by the live-event endpoint. func (h *RunHandle) Steer(ctx context.Context, id graph.NodeID, message string) error { h.mu.Lock() defer h.mu.Unlock() @@ -967,7 +969,12 @@ func (h *RunHandle) Steer(ctx context.Context, id graph.NodeID, message string) if !ok { return fmt.Errorf("node %s has no in-flight attempt", id) } - return rec.sess.Send(ctx, message) + if err := rec.sess.Send(ctx, message); err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"message": message}) + _, err := h.s.store.AppendEvent(ctx, h.runID, string(id), store.EventSteer, graph.State(""), graph.State(""), rec.attemptID, string(payload), h.s.clk.Now()) + return err } func (h *RunHandle) decideGate(ctx context.Context, id graph.NodeID, approve bool) error { diff --git a/internal/store/store.go b/internal/store/store.go index 3167f71..ae4ced0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,6 +10,7 @@ import ( "fmt" "regexp" "strings" + "sync" "time" _ "modernc.org/sqlite" @@ -27,6 +28,7 @@ const ( EventRetry EventType = "retry" // retry scheduled EventRun EventType = "run" // run lifecycle (created/completed) EventGraph EventType = "graph" // graph version change + EventSteer EventType = "steer" // operator steering message to a running attempt ) type Event struct { @@ -79,6 +81,63 @@ type NodeRow struct { type Store struct { db *sql.DB + + eventMu sync.Mutex + eventSubscribers map[*eventSubscriber]struct{} + eventsClosed bool +} + +type eventSubscriber struct { + ch chan Event +} + +// eventSubscriberBuffer decouples durable writes from live observers. A +// subscriber that cannot keep up is closed; it can replay the gap from the +// durable log using its last sequence number. +const eventSubscriberBuffer = 1024 + +// SubscribeEvents observes events after their transactions commit. Delivery +// is best effort and never blocks a store writer. The caller must unsubscribe; +// a slow subscriber is removed and its channel is closed. +func (s *Store) SubscribeEvents() (<-chan Event, func()) { + s.eventMu.Lock() + defer s.eventMu.Unlock() + if s.eventsClosed { + ch := make(chan Event) + close(ch) + return ch, func() {} + } + sub := &eventSubscriber{ch: make(chan Event, eventSubscriberBuffer)} + s.eventSubscribers[sub] = struct{}{} + return sub.ch, func() { s.unsubscribeEvents(sub) } +} + +func (s *Store) unsubscribeEvents(sub *eventSubscriber) { + s.eventMu.Lock() + defer s.eventMu.Unlock() + if _, ok := s.eventSubscribers[sub]; !ok { + return + } + delete(s.eventSubscribers, sub) + close(sub.ch) +} + +// publish notifies every subscriber without waiting. Callers invoke it only +// after the transaction containing ev has committed successfully. +func (s *Store) publish(ev Event) { + s.eventMu.Lock() + defer s.eventMu.Unlock() + if s.eventsClosed { + return + } + for sub := range s.eventSubscribers { + select { + case sub.ch <- ev: + default: + delete(s.eventSubscribers, sub) + close(sub.ch) + } + } } func Open(path string) (*Store, error) { @@ -111,10 +170,22 @@ func Open(path string) (*Store, error) { db.Close() return nil, err } - return &Store{db: db}, nil + return &Store{db: db, eventSubscribers: map[*eventSubscriber]struct{}{}}, nil } -func (s *Store) Close() error { return s.db.Close() } +func (s *Store) Close() error { + err := s.db.Close() + s.eventMu.Lock() + if !s.eventsClosed { + s.eventsClosed = true + for sub := range s.eventSubscribers { + close(sub.ch) + } + s.eventSubscribers = nil + } + s.eventMu.Unlock() + return err +} func migrate(db *sql.DB) error { schema := ` @@ -211,12 +282,15 @@ func (s *Store) CreateRun(ctx context.Context, runID string, g *graph.Graph, aut return err } } - if _, err := tx.ExecContext(ctx, - `INSERT INTO events(run_id, seq, etype, payload, created_at) VALUES(?, 1, 'run', '{"status":"created"}', ?)`, - runID, now.UnixMilli()); err != nil { + ev, err := s.appendEventTx(ctx, tx, runID, 1, "", EventRun, "", "", "", "", `{"status":"created"}`, now) + if err != nil { return err } - return tx.Commit() + if err := tx.Commit(); err != nil { + return err + } + s.publish(*ev) + return nil } func (s *Store) Run(ctx context.Context, runID string) (*Run, error) { @@ -274,10 +348,15 @@ func (s *Store) CompleteRun(ctx context.Context, runID string, status string, no if _, err := tx.ExecContext(ctx, `UPDATE runs SET status = ? WHERE id = ?`, status, runID); err != nil { return err } - if _, err := appendEventTx(ctx, tx, runID, 0, "", EventRun, "", "", "", "", `{"status":"`+status+`"}`, now); err != nil { + ev, err := s.appendEventTx(ctx, tx, runID, 0, "", EventRun, "", "", "", "", `{"status":"`+status+`"}`, now) + if err != nil { + return err + } + if err := tx.Commit(); err != nil { return err } - return tx.Commit() + s.publish(*ev) + return nil } // AppendTransition records a state change and updates the materialized @@ -288,7 +367,7 @@ func (s *Store) AppendTransition(ctx context.Context, runID string, nodeID strin return 0, err } defer tx.Rollback() - seq, err := appendEventTx(ctx, tx, runID, 0, nodeID, EventTransition, string(from), string(to), "", "", payload, now) + ev, err := s.appendEventTx(ctx, tx, runID, 0, nodeID, EventTransition, string(from), string(to), "", "", payload, now) if err != nil { return 0, err } @@ -297,7 +376,11 @@ func (s *Store) AppendTransition(ctx context.Context, runID string, nodeID strin string(to), now.UnixMilli(), runID, nodeID); err != nil { return 0, err } - return seq, tx.Commit() + if err := tx.Commit(); err != nil { + return 0, err + } + s.publish(*ev) + return ev.Seq, nil } func (s *Store) AppendEvent(ctx context.Context, runID, nodeID string, typ EventType, from, to graph.State, attemptID, payload string, now time.Time) (int64, error) { @@ -306,38 +389,60 @@ func (s *Store) AppendEvent(ctx context.Context, runID, nodeID string, typ Event return 0, err } defer tx.Rollback() - seq, err := appendEventTx(ctx, tx, runID, 0, nodeID, typ, string(from), string(to), attemptID, "", payload, now) + ev, err := s.appendEventTx(ctx, tx, runID, 0, nodeID, typ, string(from), string(to), attemptID, "", payload, now) if err != nil { return 0, err } - return seq, tx.Commit() + if err := tx.Commit(); err != nil { + return 0, err + } + s.publish(*ev) + return ev.Seq, nil } -// appendEventTx computes the next per-run sequence and inserts the event. -func appendEventTx(ctx context.Context, tx *sql.Tx, runID string, forceSeq int64, nodeID string, typ EventType, from, to, attemptID, _, payload string, now time.Time) (int64, error) { +// appendEventTx computes the next per-run sequence, inserts the event and +// returns the fully populated event for the caller to publish after commit. +func (s *Store) appendEventTx(ctx context.Context, tx *sql.Tx, runID string, forceSeq int64, nodeID string, typ EventType, from, to, attemptID, _, payload string, now time.Time) (*Event, error) { var seq int64 if forceSeq > 0 { seq = forceSeq } else { if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE run_id = ?`, runID).Scan(&seq); err != nil { - return 0, err + return nil, err } } + redacted := Redact(payload) if _, err := tx.ExecContext(ctx, `INSERT INTO events(run_id, seq, node_id, etype, from_state, to_state, attempt_id, payload, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, - runID, seq, nodeID, string(typ), from, to, attemptID, Redact(payload), now.UnixMilli()); err != nil { - return 0, err + runID, seq, nodeID, string(typ), from, to, attemptID, redacted, now.UnixMilli()); err != nil { + return nil, err } - return seq, nil + return &Event{ + Seq: seq, + RunID: runID, + NodeID: nodeID, + Type: typ, + From: graph.State(from), + To: graph.State(to), + AttemptID: attemptID, + Payload: json.RawMessage(redacted), + CreatedAt: now.UnixMilli(), + }, nil } // Events returns the full event log of a run in sequence order. func (s *Store) Events(ctx context.Context, runID string) ([]Event, error) { + return s.EventsAfter(ctx, runID, 0) +} + +// EventsAfter returns the events of a run with seq greater than after, in +// sequence order. It backs both replay and the live-event SSE cursor. +func (s *Store) EventsAfter(ctx context.Context, runID string, after int64) ([]Event, error) { rows, err := s.db.QueryContext(ctx, `SELECT seq, node_id, etype, from_state, to_state, attempt_id, payload, created_at - FROM events WHERE run_id = ? ORDER BY seq`, runID) + FROM events WHERE run_id = ? AND seq > ? ORDER BY seq`, runID, after) if err != nil { return nil, err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dd5a7a5..42fed77 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -67,6 +67,53 @@ func TestCreateRunAndReplay(t *testing.T) { } } +func TestEventSubscriptionsPublishAfterCommitToMultipleObservers(t *testing.T) { + st := open(t) + ctx := context.Background() + one, unsubscribeOne := st.SubscribeEvents() + defer unsubscribeOne() + two, unsubscribeTwo := st.SubscribeEvents() + defer unsubscribeTwo() + + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { + t.Fatal(err) + } + for i, events := range []<-chan Event{one, two} { + select { + case event := <-events: + if event.RunID != "r1" || event.Seq != 1 || event.Type != EventRun { + t.Fatalf("observer %d got %+v", i, event) + } + persisted, err := st.EventsAfter(ctx, "r1", 0) + if err != nil || len(persisted) != 1 || persisted[0].Seq != event.Seq { + t.Fatalf("observer %d notified before durable read: events=%+v err=%v", i, persisted, err) + } + case <-time.After(time.Second): + t.Fatalf("observer %d received no event", i) + } + } +} + +func TestEventsAfterUsesExclusiveCursor(t *testing.T) { + st := open(t) + ctx := context.Background() + if err := st.CreateRun(ctx, "r1", testGraph(t), false, now()); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if _, err := st.AppendEvent(ctx, "r1", "a", EventGraph, "", "", "", `{}`, now()); err != nil { + t.Fatal(err) + } + } + events, err := st.EventsAfter(ctx, "r1", 1) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[0].Seq != 2 || events[1].Seq != 3 { + t.Fatalf("events after 1 = %+v", events) + } +} + func TestLeaseAtomicity(t *testing.T) { st := open(t) ctx := context.Background() diff --git a/internal/tui/client_test.go b/internal/tui/client_test.go index 6b4c78e..c1d2b15 100644 --- a/internal/tui/client_test.go +++ b/internal/tui/client_test.go @@ -32,6 +32,7 @@ func TestClientAgainstDaemon(t *testing.T) { eng := verify.New(workdir) s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{Concurrency: 2}) d := daemon.New(st, s, nil, t.TempDir(), "") + t.Cleanup(d.Close) ctx, cancel := context.WithCancel(context.Background()) defer cancel() d.SetContext(ctx) From 4083b601bb7ae5e602afaf5d37808cb88103e45e Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:48:34 -0300 Subject: [PATCH 15/26] fix: retain event subscribers after overload --- internal/store/store.go | 10 +++++----- internal/store/store_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/store/store.go b/internal/store/store.go index ae4ced0..82e940a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -92,13 +92,13 @@ type eventSubscriber struct { } // eventSubscriberBuffer decouples durable writes from live observers. A -// subscriber that cannot keep up is closed; it can replay the gap from the -// durable log using its last sequence number. +// subscriber that cannot keep up misses wakeups until it catches up, then +// reconciles the gap from the durable log using its last sequence number. const eventSubscriberBuffer = 1024 // SubscribeEvents observes events after their transactions commit. Delivery // is best effort and never blocks a store writer. The caller must unsubscribe; -// a slow subscriber is removed and its channel is closed. +// a slow subscriber stays attached so later wakeups resume after it catches up. func (s *Store) SubscribeEvents() (<-chan Event, func()) { s.eventMu.Lock() defer s.eventMu.Unlock() @@ -134,8 +134,8 @@ func (s *Store) publish(ev Event) { select { case sub.ch <- ev: default: - delete(s.eventSubscribers, sub) - close(sub.ch) + // Notifications are wakeups, not the source of truth. Dropping one + // is safe; the next delivered wakeup triggers durable cursor replay. } } } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 42fed77..7d4941e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -94,6 +94,38 @@ func TestEventSubscriptionsPublishAfterCommitToMultipleObservers(t *testing.T) { } } +func TestSlowEventSubscriberRecoversAfterDroppedWakeup(t *testing.T) { + st := open(t) + events, unsubscribe := st.SubscribeEvents() + defer unsubscribe() + + // Fill the best-effort wakeup queue, then overflow it once. Overflow may + // drop that wakeup, but must not permanently detach the subscriber: its + // consumer can reconcile from the durable log and keep listening. + for i := 1; i <= eventSubscriberBuffer+1; i++ { + st.publish(Event{Seq: int64(i), RunID: "r1", Type: EventGraph}) + } + for i := 0; i < eventSubscriberBuffer; i++ { + if _, ok := <-events; !ok { + t.Fatal("slow subscriber was permanently closed on overflow") + } + } + + const marker = int64(10_000) + st.publish(Event{Seq: marker, RunID: "r1", Type: EventGraph}) + select { + case event, ok := <-events: + if !ok { + t.Fatal("subscriber remained closed after catching up") + } + if event.Seq != marker { + t.Fatalf("event seq = %d, want %d", event.Seq, marker) + } + case <-time.After(time.Second): + t.Fatal("subscriber did not receive events after catching up") + } +} + func TestEventsAfterUsesExclusiveCursor(t *testing.T) { st := open(t) ctx := context.Background() From ccef5ab29c2802ee7b1d8ffc08b80e15eea0e8fb Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:18:33 -0300 Subject: [PATCH 16/26] feat: add live TUI telemetry and attention --- cmd/corral/main.go | 4 +- internal/daemon/daemon.go | 28 ++++++++ internal/daemon/hardening_test.go | 2 +- internal/daemon/openapi.go | 10 +++ internal/sched/sched.go | 36 ++++++++++ internal/tui/api.go | 15 +++- internal/tui/client_test.go | 70 +++++++++++++++++++ internal/tui/model.go | 91 ++++++++++++++++++++++++- internal/tui/notify.go | 39 +++++++++++ internal/tui/tui_test.go | 94 ++++++++++++++++++++++++++ internal/tui/view.go | 109 +++++++++++++++++++++++++++++- 11 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 internal/tui/notify.go diff --git a/cmd/corral/main.go b/cmd/corral/main.go index 1f4d3e4..fa141dd 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -229,7 +229,9 @@ func tuiCmd() error { client := tui.NewClient(base, key) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - p := tea.NewProgram(tui.New(client, ctx), tea.WithAltScreen()) + model := tui.New(client, ctx) + model.EnableAttention() + p := tea.NewProgram(model, tea.WithAltScreen()) _, err := p.Run() return err } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index bd3bd4b..4e836ea 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -139,6 +139,7 @@ func (d *Daemon) Handler() http.Handler { mux.HandleFunc("GET /api/runs", d.handleListRuns) mux.HandleFunc("GET /api/runs/{id}", d.handleGetRun) mux.HandleFunc("GET /api/runs/{id}/watch", d.handleWatchRun) + mux.HandleFunc("GET /api/runs/{id}/tail", d.handleTail) mux.HandleFunc("POST /api/runs/{id}/approve", d.role(RoleOperator, RoleOrchestrator)(d.handleApprove)) mux.HandleFunc("POST /api/runs/{id}/reject", d.role(RoleOperator, RoleOrchestrator)(d.handleReject)) mux.HandleFunc("POST /api/runs/{id}/cancel", d.role(RoleOperator, RoleOrchestrator)(d.handleCancel)) @@ -462,6 +463,33 @@ func (d *Daemon) nodeAction(w http.ResponseWriter, r *http.Request, fn func(ctx writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +// handleTail returns the live transcript tail of a node's in-flight +// attempt (query params: node, lines). Used by the TUI's inspect view. +func (d *Daemon) handleTail(w http.ResponseWriter, r *http.Request) { + node := r.URL.Query().Get("node") + if node == "" { + http.Error(w, "node required", http.StatusBadRequest) + return + } + lines := 40 + if v := r.URL.Query().Get("lines"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + lines = n + } + } + h, err := d.runHandle(r.PathValue("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + tail, err := h.Tail(r.Context(), graph.NodeID(node), lines) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + writeJSON(w, http.StatusOK, map[string]any{"node": node, "lines": tail}) +} + func (d *Daemon) handleApprove(w http.ResponseWriter, r *http.Request) { h, err := d.runHandle(r.PathValue("id")) if err != nil { diff --git a/internal/daemon/hardening_test.go b/internal/daemon/hardening_test.go index fe80c75..4c0d88a 100644 --- a/internal/daemon/hardening_test.go +++ b/internal/daemon/hardening_test.go @@ -83,7 +83,7 @@ func TestOpenAPIContract(t *testing.T) { paths, _ := doc["paths"].(map[string]any) registered := []string{ "/api/health", "/api/plan", "/api/runs", "/api/runs/{id}", - "/api/runs/{id}/watch", "/api/runs/{id}/events", + "/api/runs/{id}/watch", "/api/runs/{id}/events", "/api/runs/{id}/tail", "/api/runs/{id}/approve", "/api/runs/{id}/reject", "/api/runs/{id}/cancel", "/api/runs/{id}/retry", "/api/runs/{id}/steer", "/api/runs/{id}/permission", "/api/runs/{id}/export", "/doc", diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index 36b4712..2f6aad4 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -28,6 +28,9 @@ const OpenAPI = `{ "/api/runs/{id}/watch": { "get": {"parameters": [{"name": "since", "in": "query", "schema": {"type": "integer"}}, {"name": "timeout", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "run snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WatchResponse"}}}}}} }, + "/api/runs/{id}/tail": { + "get": {"responses": {"200": {"description": "live attempt tail", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tail"}}}}}} + }, "/api/runs/{id}/approve": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/reject": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/cancel": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, @@ -114,6 +117,13 @@ const OpenAPI = `{ "attemptID": {"type": "string"}, "payload": {}, "createdAt": {"type": "integer"} } }, + "Tail": { + "type": "object", "required": ["node", "lines"], + "properties": { + "node": {"type": "string"}, + "lines": {"type": "array", "items": {"type": "string"}} + } + }, "Artifact": { "type": "object", "required": ["attemptID", "name", "hash"], "properties": { diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 1c6cd5c..15531a8 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" "sync" "time" @@ -977,6 +978,41 @@ func (h *RunHandle) Steer(ctx context.Context, id graph.NodeID, message string) return err } +// Tail returns the last n transcript lines of the in-flight attempt of a +// node, for live output in the companion TUI. Empty when the node has no +// live session (e.g. inline check/gate nodes). +func (h *RunHandle) Tail(ctx context.Context, id graph.NodeID, n int) ([]string, error) { + h.mu.Lock() + defer h.mu.Unlock() + rec, ok := h.sessions[id] + if !ok { + return nil, fmt.Errorf("node %s has no in-flight attempt", id) + } + msgs, err := rec.sess.Messages(ctx) + if err != nil { + return nil, err + } + return transcriptLines(msgs, n), nil +} + +// transcriptLines flattens a session transcript into text lines and keeps +// only the last n of them (a "tail"). +func transcriptLines(msgs []adapter.Message, n int) []string { + var lines []string + for _, m := range msgs { + for _, ln := range strings.Split(m.Text, "\n") { + if ln == "" { + continue + } + lines = append(lines, ln) + } + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines +} + func (h *RunHandle) decideGate(ctx context.Context, id graph.NodeID, approve bool) error { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/tui/api.go b/internal/tui/api.go index e474747..820f2f1 100644 --- a/internal/tui/api.go +++ b/internal/tui/api.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/url" "time" ) @@ -38,7 +39,9 @@ type GraphNode struct { MaxRetries int `json:"maxRetries"` } `json:"retryPolicy"` Budget struct { - MaxDuration int64 `json:"maxDuration"` // nanoseconds (time.Duration) + MaxDuration int64 `json:"maxDuration"` // nanoseconds (time.Duration) + MaxTokens int `json:"maxTokens,omitempty"` + MaxCost float64 `json:"maxCost,omitempty"` } `json:"budget"` } @@ -82,6 +85,7 @@ type RunDetail struct { type API interface { ListRuns(ctx context.Context) ([]RunSummary, error) GetRun(ctx context.Context, runID string) (*RunDetail, error) + Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) Approve(ctx context.Context, runID, nodeID string) error Reject(ctx context.Context, runID, nodeID string) error Cancel(ctx context.Context, runID, nodeID string) error @@ -152,6 +156,15 @@ func (c *Client) GetRun(ctx context.Context, runID string) (*RunDetail, error) { return &out, err } +func (c *Client) Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) { + var out struct { + Lines []string `json:"lines"` + } + path := fmt.Sprintf("/api/runs/%s/tail?node=%s&lines=%d", runID, url.QueryEscape(nodeID), lines) + err := c.do(ctx, http.MethodGet, path, nil, &out) + return out.Lines, err +} + func (c *Client) nodeAction(ctx context.Context, path, runID, nodeID string) error { return c.do(ctx, http.MethodPost, path, map[string]string{"nodeID": nodeID}, nil) } diff --git a/internal/tui/client_test.go b/internal/tui/client_test.go index c1d2b15..1036be9 100644 --- a/internal/tui/client_test.go +++ b/internal/tui/client_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "corral/internal/adapter" "corral/internal/clock" "corral/internal/daemon" "corral/internal/graph" @@ -236,3 +237,72 @@ func TestClientRespondPermission(t *testing.T) { } t.Fatal("node never done after permission allowed") } + +// TestTailAgainstDaemon exercises the tail endpoint against a live daemon +// with a running attempt: the transcript lines stream while the node runs. +func TestTailAgainstDaemon(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + workdir := t.TempDir() + drv := sched.NewFakeDriver(clock.Real{}, nil) + eng := verify.New(workdir) + s := sched.New(st, drv, &sched.EngineVerifier{Eng: eng}, clock.Real{}, sched.Options{Concurrency: 2}) + d := daemon.New(st, s, nil, t.TempDir(), "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + d.SetContext(ctx) + srv := httptest.NewServer(d.Handler()) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "") + client.Role = "operator" + drv.SetScript("w1", sched.Script{ + Delay: 5 * time.Second, + Messages: []adapter.Message{ + {Role: "assistant", Text: "inspecting the workspace"}, + {Role: "assistant", Text: "found the bug\napplying the fix"}, + }, + }) + g := &graph.Graph{Nodes: []*graph.Node{{ + ID: "w1", Type: graph.NodeAgent, Role: "worker", + Objective: "write a.txt", AcceptanceCriteria: []string{"a.txt"}, + Priority: graph.PriorityNormal, WriteScope: []string{"a.txt"}, + Verification: &graph.Verification{Kind: "command", Command: []string{"test", "-f", "a.txt"}}, + Meta: map[string]string{"cwd": workdir}, + }}} + var created struct{ RunID string } + if err := client.do(ctx, "POST", "/api/runs", map[string]any{"graph": g}, &created); err != nil { + t.Fatal(err) + } + + // Wait until the node is running, then fetch the tail. + deadline := time.Now().Add(10 * time.Second) + var dd *RunDetail + for time.Now().Before(deadline) { + dd, _ = client.GetRun(ctx, created.RunID) + if dd != nil && dd.States["w1"] == "running" { + break + } + time.Sleep(50 * time.Millisecond) + } + if dd == nil || dd.States["w1"] != "running" { + t.Fatal("node never reached running") + } + lines, err := client.Tail(ctx, created.RunID, "w1", 40) + if err != nil { + t.Fatalf("tail: %v", err) + } + if len(lines) == 0 { + t.Fatal("tail returned no lines while running") + } + joined := strings.Join(lines, "\n") + for _, want := range []string{"inspecting", "applying"} { + if !strings.Contains(joined, want) { + t.Fatalf("tail missing %q: %q", want, joined) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 0dc36fa..f4a0a7a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -25,6 +25,12 @@ type fetchRunMsg struct { err error } +type tailMsg struct { + node string + lines []string + err error +} + type actionMsg struct { label string err error @@ -57,6 +63,20 @@ type Model struct { steerNode string steerInput string + // tail holds the last-fetched live attempt tail for the inspected + // node. tailNode is set when the inspect view is active. + tailNode string + tail []string + + // prevStates records the last seen node states so attention only + // fires on transitions (gate awaiting approval, node failed). + prevStates map[string]string + // notified remembers which attention conditions have already been + // announced, keyed by runID/nodeID/condition. + notified map[string]bool + // notify delivers terminal attention; overridden in tests. + notify func(title, body string) + status string err error @@ -68,6 +88,12 @@ func New(api API, ctx context.Context) *Model { return &Model{api: api, ctx: ctx, tick: time.Second} } +// EnableAttention arms terminal attention notifications (bell + desktop +// notification) for gates awaiting approval and failed nodes. The model +// ships with attention disabled so unit tests stay side-effect free; the +// real TUI calls this once at startup. +func (m *Model) EnableAttention() { m.notify = NotifyAttention } + func (m *Model) Init() tea.Cmd { return tea.Batch(fetchRunsCmd(m), tickCmd(m.tick)) } @@ -86,6 +112,13 @@ func fetchRunCmd(m *Model) tea.Cmd { } } +func fetchTailCmd(m *Model) tea.Cmd { + return func() tea.Msg { + lines, err := m.api.Tail(m.ctx, m.selectedID, m.tailNode, 40) + return tailMsg{node: m.tailNode, lines: lines, err: err} + } +} + func actionCmd(m *Model, label string, fn func(context.Context) error) tea.Cmd { return func() tea.Msg { return actionMsg{label: label, err: fn(m.ctx)} @@ -108,7 +141,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case modeList: return m, tea.Batch(cmd, fetchRunsCmd(m)) case modeDetail, modeInspect: - return m, tea.Batch(cmd, fetchRunCmd(m)) + cmds := []tea.Cmd{cmd, fetchRunCmd(m)} + if m.mode == modeInspect && m.tailNode != "" { + cmds = append(cmds, fetchTailCmd(m)) + } + return m, tea.Batch(cmds...) } return m, cmd @@ -135,6 +172,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.nodeCursor >= len(m.detail.Graph.Nodes) { m.nodeCursor = 0 } + m.checkAttention() + } + return m, nil + + case tailMsg: + if v.err == nil && v.node == m.tailNode { + m.tail = v.lines } return m, nil @@ -205,6 +249,8 @@ func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "i": if id, ok := m.nodeAt(m.nodeCursor); ok { m.inspectNode = id + m.tailNode = id + m.tail = nil m.mode = modeInspect return m, nil } @@ -301,6 +347,8 @@ func (m *Model) back() { switch m.mode { case modeInspect: m.mode = modeDetail + m.tailNode = "" + m.tail = nil case modeSteer: m.mode = modeDetail case modeDetail: @@ -356,3 +404,44 @@ func (m *Model) SelectedNode() (string, bool) { return m.nodeAt(m.nodeCursor) } // CurrentRun returns the selected run id (for tests). func (m *Model) CurrentRun() string { return m.selectedID } + +// checkAttention fires a terminal attention notification when a node's +// state demands it: a human gate awaiting approval (running) or any node +// that fails. Each condition is announced once per transition. +func (m *Model) checkAttention() { + if m.detail == nil || m.notify == nil { + return + } + if m.prevStates == nil { + m.prevStates = map[string]string{} + } + if m.notified == nil { + m.notified = map[string]bool{} + } + for _, n := range m.detail.Graph.Nodes { + cur := m.detail.States[n.ID] + prev := m.prevStates[n.ID] + m.prevStates[n.ID] = cur + key := m.detail.RunID + "/" + n.ID + + // Drop remembered conditions that no longer hold, so a later + // recurrence (retry, re-opened gate) announces again. + if cur != "running" { + delete(m.notified, key+"/gate") + } + if cur != "failed" { + delete(m.notified, key+"/failed") + } + + if cur == "running" && prev != "running" && n.Type == "human_gate" && !m.notified[key+"/gate"] { + m.notified[key+"/gate"] = true + m.notify("corral: gate awaits approval", + fmt.Sprintf("gate %s on run %s awaits approval", n.ID, m.detail.RunID)) + } + if cur == "failed" && prev != "failed" && !m.notified[key+"/failed"] { + m.notified[key+"/failed"] = true + m.notify("corral: node failed", + fmt.Sprintf("node %s on run %s failed", n.ID, m.detail.RunID)) + } + } +} diff --git a/internal/tui/notify.go b/internal/tui/notify.go new file mode 100644 index 0000000..597d76c --- /dev/null +++ b/internal/tui/notify.go @@ -0,0 +1,39 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +// NotifyAttention raises terminal attention when the run needs a human: +// a terminal bell, plus a desktop notification on macOS (osascript) and +// Linux (notify-send). Best-effort and non-blocking; failures are ignored. +func NotifyAttention(title, body string) { + ringBell() + cmd := desktopNotify(title, body) + if cmd != nil { + _ = cmd.Start() + } +} + +// ringBell writes a terminal bell (BEL) to stdout. This is safe inside a +// bubbletea program: BEL is a control character that does not disturb the +// rendered frame. +func ringBell() { + _, _ = fmt.Fprint(os.Stdout, "\a") +} + +// desktopNotify returns the platform notification command, or nil when the +// platform has no supported notifier. +func desktopNotify(title, body string) *exec.Cmd { + switch runtime.GOOS { + case "darwin": + return exec.Command("osascript", "-e", + fmt.Sprintf(`display notification %q with title %q`, body, title)) + case "linux": + return exec.Command("notify-send", title, body) + } + return nil +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 166c00b..af06fac 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -17,6 +17,8 @@ type fakeAPI struct { actions []string listErr error detailErr error + tailErr error + tail []string } func (f *fakeAPI) ListRuns(ctx context.Context) ([]RunSummary, error) { @@ -33,6 +35,13 @@ func (f *fakeAPI) GetRun(ctx context.Context, runID string) (*RunDetail, error) return f.detail, nil } +func (f *fakeAPI) Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) { + if f.tailErr != nil { + return nil, f.tailErr + } + return f.tail, nil +} + func (f *fakeAPI) act(label string) { f.actions = append(f.actions, label) } @@ -229,6 +238,91 @@ func TestEmptyState(t *testing.T) { } } +func TestLiveAttemptTail(t *testing.T) { + api := &fakeAPI{detail: sampleDetail(), tail: []string{"alpha", "beta", "gamma"}} + m := New(api, context.Background()) + m.selectedID = "run_1" + m.detail = sampleDetail() + m.mode = modeDetail + m.nodeCursor = 1 // gate + m.Update(key("i")) + if m.mode != modeInspect || m.tailNode != "gate" { + t.Fatalf("inspect mode = %d tailNode=%q", m.mode, m.tailNode) + } + // Simulate the tail fetch result. + m.Update(tailMsg{node: "gate", lines: api.tail}) + view := m.View() + for _, want := range []string{"live tail", "alpha", "beta", "gamma"} { + if !strings.Contains(view, want) { + t.Fatalf("inspect view missing %q:\n%s", want, view) + } + } + // Back clears the tail so it stops being fetched. + m.Update(key("esc")) + if m.tailNode != "" || len(m.tail) != 0 { + t.Fatalf("tail not cleared on back: node=%q tail=%v", m.tailNode, m.tail) + } +} + +func TestAttentionOnGateAndFailure(t *testing.T) { + var got []string + api := &fakeAPI{detail: sampleDetail()} + m := New(api, context.Background()) + m.notify = func(title, body string) { got = append(got, title+" | "+body) } + m.selectedID = "run_1" + m.detail = sampleDetail() + + // First fetch: gate is running → gate attention fires. + m.Update(fetchRunMsg{detail: m.detail}) + if len(got) != 1 || !strings.Contains(got[0], "gate") { + t.Fatalf("gate attention not fired: %v", got) + } + + // Same state again: no duplicate. + m.Update(fetchRunMsg{detail: m.detail}) + if len(got) != 1 { + t.Fatalf("duplicate attention fired: %v", got) + } + + // A node fails → failure attention fires. + detail := sampleDetail() + detail.States["w1"] = "failed" + m.Update(fetchRunMsg{detail: detail}) + if len(got) != 2 || !strings.Contains(got[1], "failed") { + t.Fatalf("failure attention not fired: %v", got) + } +} + +func TestAttentionDisabledByDefault(t *testing.T) { + api := &fakeAPI{detail: sampleDetail()} + m := New(api, context.Background()) + m.selectedID = "run_1" + m.detail = sampleDetail() + m.Update(fetchRunMsg{detail: m.detail}) + if m.notified["run_1/gate/gate"] { + t.Fatal("attention should be disabled unless EnableAttention is called") + } +} + +func TestBudgetBarInDetail(t *testing.T) { + d := sampleDetail() + d.Graph.Nodes[0].Budget.MaxDuration = int64(2 * time.Second) + d.Attempts["w1"] = []AttemptView{{ID: "w1/1", No: 1, Status: "done", StartedAt: int64Ptr(1000), FinishedAt: int64Ptr(2000)}} + m := New(&fakeAPI{detail: d}, context.Background()) + m.selectedID = "run_1" + m.detail = d + m.mode = modeDetail + view := m.View() + if !strings.Contains(view, "progress") { + t.Fatalf("detail view missing progress bar:\n%s", view) + } + if !strings.Contains(view, "1s/2s") { + t.Fatalf("detail view missing budget usage:\n%s", view) + } +} + +func int64Ptr(v int64) *int64 { return &v } + func TestFetchErrorShown(t *testing.T) { api := &fakeAPI{listErr: fmt.Errorf("connection refused")} m := New(api, context.Background()) diff --git a/internal/tui/view.go b/internal/tui/view.go index 299d809..de1a3d2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -19,6 +19,27 @@ var ( styleMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) ) +// progressBar renders a horizontal bar of the given width with the filled +// fraction (0..1). Empty fill uses a bright color, so near-full usage is +// easy to spot. +func progressBar(frac float64, width int) string { + if frac < 0 { + frac = 0 + } + if frac > 1 { + frac = 1 + } + filled := int(frac*float64(width) + 0.5) + body := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + if frac >= 1 { + return styleError.Render(body) + } + if frac >= 0.85 { + return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(body) + } + return styleOK.Render(body) +} + func stateColor(s string) lipgloss.Style { switch s { case "done": @@ -82,6 +103,18 @@ func (m *Model) viewDetail() string { if m.detail.Done { b.WriteString(styleOK.Render(" ✓ done")) } + // Overall run progress: done nodes / total. + total := len(m.detail.Graph.Nodes) + if total > 0 { + done := 0 + for _, n := range m.detail.Graph.Nodes { + if m.detail.States[n.ID] == "done" { + done++ + } + } + b.WriteString("\n" + styleDim.Render("progress ") + progressBar(float64(done)/float64(total), 20) + + styleMuted.Render(fmt.Sprintf(" %d/%d done", done, total))) + } b.WriteString("\n\n") // DAG view: nodes in dependency order, arrows between them. @@ -132,7 +165,70 @@ func (m *Model) nodeLine(n GraphNode, deps int) string { permS = styleTitle.Render(fmt.Sprintf("perm:%s", pid)) } } - return fmt.Sprintf("%-12s %s %-7s %s %s %s %s", n.ID, st, typ, prio, attempts, depsS, permS) + bar := m.nodeBudgetBar(n, atts, state) + return fmt.Sprintf("%-12s %s %-7s %s %s %s %s %s", n.ID, st, typ, prio, attempts, depsS, bar, permS) +} + +// nodeBudgetBar renders a compact budget-usage bar for a node. The +// dominant budget dimension (time, tokens, or cost) drives the bar, with +// the used/limit figures beside it. Nodes without a budget show "". +func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, state string) string { + maxDur := n.Budget.MaxDuration + maxTok := n.Budget.MaxTokens + maxCost := n.Budget.MaxCost + if maxDur <= 0 && maxTok <= 0 && maxCost <= 0 { + return "" + } + // Used figures, from the most recent attempt for time and the sum for + // tokens/cost. + var usedDur time.Duration + usedTok := 0 + usedCost := 0.0 + if len(atts) > 0 { + at := atts[len(atts)-1] + start := m.now() + if at.StartedAt != nil { + start = time.UnixMilli(*at.StartedAt) + } + end := m.now() + if at.FinishedAt != nil { + end = time.UnixMilli(*at.FinishedAt) + } + if end.After(start) { + usedDur = end.Sub(start) + } + } + for _, at := range atts { + usedTok += at.Tokens + usedCost += at.Cost + } + // Choose the dominant dimension. + var frac float64 + var label string + switch { + case maxDur > 0: + frac = float64(usedDur) / float64(time.Duration(maxDur)) + label = fmt.Sprintf("%s/%s", usedDur.Round(time.Second), time.Duration(maxDur).Round(time.Second)) + case maxTok > 0: + frac = float64(usedTok) / float64(maxTok) + label = fmt.Sprintf("%dk/%dk", usedTok/1000, maxTok/1000) + default: + frac = usedCost / maxCost + label = fmt.Sprintf("$%.2f/%.2f", usedCost, maxCost) + } + if state == "done" { + frac = 1 + } + return progressBar(frac, 8) + " " + styleMuted.Render(label) +} + +// now returns the model's wall-clock reference (last tick time, or real +// time before the first tick) for elapsed computations. +func (m *Model) now() time.Time { + if m.lastFetch.IsZero() { + return time.Now() + } + return m.lastFetch } func (m *Model) viewInspect() string { @@ -179,6 +275,17 @@ func (m *Model) viewInspect() string { b.WriteString(styleMuted.Render(" evidence: "+shortLine(at.Evidence, 90)) + "\n") } } + // Live attempt tail for the current (running) attempt. + if state == "running" || state == "verifying" { + b.WriteString("\n" + styleDim.Render("live tail") + "\n") + if len(m.tail) == 0 { + b.WriteString(styleMuted.Render(" (no output yet)\n")) + } else { + for _, ln := range m.tail { + b.WriteString(styleMuted.Render(" "+shortLine(ln, 90)) + "\n") + } + } + } b.WriteString("\n" + m.footer("esc back · ↑/↓ navigate · a/r/c/t/s act · p/d respond perm")) return b.String() } From 7da12053c2fcf534cdf2889c9fba5c644dfe68dc Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:42:36 -0300 Subject: [PATCH 17/26] fix: harden live TUI event handling --- internal/daemon/daemon.go | 14 +- internal/daemon/openapi.go | 2 +- internal/sched/sched.go | 16 +- internal/sched/tail_test.go | 65 ++++++++ internal/tui/api.go | 128 ++++++++++++++- internal/tui/client_test.go | 46 ++++++ internal/tui/model.go | 318 ++++++++++++++++++++++++++++++------ internal/tui/notify.go | 51 +++++- internal/tui/notify_test.go | 39 +++++ internal/tui/tui_test.go | 252 ++++++++++++++++++++++++++-- internal/tui/view.go | 56 ++++--- 11 files changed, 888 insertions(+), 99 deletions(-) create mode 100644 internal/sched/tail_test.go create mode 100644 internal/tui/notify_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4e836ea..fb8b7c1 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -466,16 +466,20 @@ func (d *Daemon) nodeAction(w http.ResponseWriter, r *http.Request, fn func(ctx // handleTail returns the live transcript tail of a node's in-flight // attempt (query params: node, lines). Used by the TUI's inspect view. func (d *Daemon) handleTail(w http.ResponseWriter, r *http.Request) { - node := r.URL.Query().Get("node") - if node == "" { + q := r.URL.Query() + node := q.Get("node") + if node == "" || len(node) > 256 { http.Error(w, "node required", http.StatusBadRequest) return } lines := 40 - if v := r.URL.Query().Get("lines"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { - lines = n + if v := q.Get("lines"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 500 { + http.Error(w, "lines must be between 1 and 500", http.StatusBadRequest) + return } + lines = n } h, err := d.runHandle(r.PathValue("id")) if err != nil { diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index 2f6aad4..ff44d39 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -29,7 +29,7 @@ const OpenAPI = `{ "get": {"parameters": [{"name": "since", "in": "query", "schema": {"type": "integer"}}, {"name": "timeout", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "run snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WatchResponse"}}}}}} }, "/api/runs/{id}/tail": { - "get": {"responses": {"200": {"description": "live attempt tail", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tail"}}}}}} + "get": {"parameters": [{"name": "node", "in": "query", "required": true, "schema": {"type": "string", "maxLength": 256}}, {"name": "lines", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 40}}], "responses": {"200": {"description": "live attempt tail", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tail"}}}}, "400": {"description": "invalid node or line count"}, "404": {"description": "run not found"}, "409": {"description": "node has no live attempt"}}} }, "/api/runs/{id}/approve": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, "/api/runs/{id}/reject": {"post": {"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ok"}}}}}}}, diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 15531a8..5350935 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -983,12 +983,20 @@ func (h *RunHandle) Steer(ctx context.Context, id graph.NodeID, message string) // live session (e.g. inline check/gate nodes). func (h *RunHandle) Tail(ctx context.Context, id graph.NodeID, n int) ([]string, error) { h.mu.Lock() - defer h.mu.Unlock() - rec, ok := h.sessions[id] - if !ok { + rec := h.sessions[id] + if rec == nil { + rec = h.suspended[id] + } + if rec == nil { + h.mu.Unlock() return nil, fmt.Errorf("node %s has no in-flight attempt", id) } - msgs, err := rec.sess.Messages(ctx) + // Session calls may perform network I/O. Copy the stable interface + // reference while protected, then release the scheduler mutex before + // waiting on the provider. + sess := rec.sess + h.mu.Unlock() + msgs, err := sess.Messages(ctx) if err != nil { return nil, err } diff --git a/internal/sched/tail_test.go b/internal/sched/tail_test.go new file mode 100644 index 0000000..48d4f18 --- /dev/null +++ b/internal/sched/tail_test.go @@ -0,0 +1,65 @@ +package sched + +import ( + "context" + "testing" + "time" + + "corral/internal/adapter" + "corral/internal/graph" +) + +type blockingMessagesSession struct { + entered chan struct{} + release chan struct{} +} + +func (s *blockingMessagesSession) ID() string { return "session" } +func (s *blockingMessagesSession) ServerID() string { return "server" } +func (s *blockingMessagesSession) Send(context.Context, string) error { + return nil +} +func (s *blockingMessagesSession) Abort(context.Context) error { return nil } +func (s *blockingMessagesSession) Status(context.Context) (adapter.Status, error) { + return adapter.StatusRunning, nil +} +func (s *blockingMessagesSession) Messages(context.Context) ([]adapter.Message, error) { + close(s.entered) + <-s.release + return []adapter.Message{{Text: "tail"}}, nil +} +func (s *blockingMessagesSession) Close(context.Context) error { return nil } + +func TestTailDoesNotHoldRunMutexWhileFetchingMessages(t *testing.T) { + sess := &blockingMessagesSession{entered: make(chan struct{}), release: make(chan struct{})} + h := &RunHandle{sessions: map[graph.NodeID]*sessionRec{ + "node": {nodeID: "node", sess: sess}, + }} + tailDone := make(chan error, 1) + go func() { + _, err := h.Tail(context.Background(), "node", 10) + tailDone <- err + }() + select { + case <-sess.entered: + case <-time.After(time.Second): + t.Fatal("Tail did not call Messages") + } + + mutexAvailable := make(chan struct{}) + go func() { + _ = h.ActiveSessions() + close(mutexAvailable) + }() + select { + case <-mutexAvailable: + // Good: Messages is still blocked, but scheduler mutex is free. + case <-time.After(100 * time.Millisecond): + close(sess.release) + t.Fatal("Tail held RunHandle mutex across Messages I/O") + } + close(sess.release) + if err := <-tailDone; err != nil { + t.Fatal(err) + } +} diff --git a/internal/tui/api.go b/internal/tui/api.go index 820f2f1..a169431 100644 --- a/internal/tui/api.go +++ b/internal/tui/api.go @@ -4,13 +4,18 @@ package tui import ( + "bufio" "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "mime" "net/http" "net/url" + "strconv" + "strings" "time" ) @@ -62,6 +67,7 @@ type AttemptView struct { type EventView struct { Seq int64 `json:"seq"` + RunID string `json:"runID,omitempty"` NodeID string `json:"nodeID,omitempty"` Type string `json:"type"` From string `json:"from,omitempty"` @@ -85,6 +91,7 @@ type RunDetail struct { type API interface { ListRuns(ctx context.Context) ([]RunSummary, error) GetRun(ctx context.Context, runID string) (*RunDetail, error) + StreamEvents(ctx context.Context, runID string, after int64, emit func(EventView) error) error Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) Approve(ctx context.Context, runID, nodeID string) error Reject(ctx context.Context, runID, nodeID string) error @@ -156,11 +163,130 @@ func (c *Client) GetRun(ctx context.Context, runID string) (*RunDetail, error) { return &out, err } +// StreamEvents consumes raw durable store.Event frames from the daemon's +// SSE endpoint. It blocks until the stream ends, context is canceled, or +// emit returns an error. Reconnect callers pass the last accepted Seq as +// after; duplicate frames are ignored defensively. +func (c *Client) StreamEvents(ctx context.Context, runID string, after int64, emit func(EventView) error) error { + if runID == "" { + return fmt.Errorf("runID required") + } + if after < 0 { + return fmt.Errorf("after must not be negative") + } + path := fmt.Sprintf("/api/runs/%s/events?after=%d", url.PathEscape(runID), after) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("X-Corral-Role", c.Role) + if c.Key != "" { + req.Header.Set("Authorization", "Bearer "+c.Key) + } + client := &http.Client{} + if c.HTTP != nil { + // SSE is intentionally long-lived, so omit the normal request timeout + // while retaining caller-supplied transport/redirect/cookie behavior. + client.Transport = c.HTTP.Transport + client.CheckRedirect = c.HTTP.CheckRedirect + client.Jar = c.HTTP.Jar + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 201)) + return fmt.Errorf("GET %s: %d %s", path, resp.StatusCode, truncate(string(data), 200)) + } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil || mediaType != "text/event-stream" { + return fmt.Errorf("GET %s: unexpected content type %q", path, resp.Header.Get("Content-Type")) + } + + cursor := after + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 4096), 1<<20) + var eventID string + var data []string + dispatch := func() error { + if len(data) == 0 { + eventID = "" + return nil + } + var event EventView + if err := json.Unmarshal([]byte(strings.Join(data, "\n")), &event); err != nil { + return fmt.Errorf("decode event: %w", err) + } + if event.Seq <= 0 { + return fmt.Errorf("event has invalid seq %d", event.Seq) + } + if eventID != "" { + id, err := strconv.ParseInt(eventID, 10, 64) + if err != nil || id != event.Seq { + return fmt.Errorf("event id %q does not match seq %d", eventID, event.Seq) + } + } + eventID, data = "", nil + if event.Seq <= cursor { + return nil + } + if emit == nil { + return errors.New("event emitter required") + } + if err := emit(event); err != nil { + return err + } + cursor = event.Seq + return nil + } + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if err := dispatch(); err != nil { + return err + } + continue + } + if strings.HasPrefix(line, ":") { + continue + } + field, value, ok := strings.Cut(line, ":") + if ok && strings.HasPrefix(value, " ") { + value = value[1:] + } + switch field { + case "id": + eventID = value + case "data": + data = append(data, value) + } + } + if err := scanner.Err(); err != nil { + return err + } + if err := dispatch(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return io.EOF +} + func (c *Client) Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) { + if runID == "" || nodeID == "" { + return nil, fmt.Errorf("runID and nodeID required") + } + if lines < 1 || lines > 500 { + return nil, fmt.Errorf("lines must be between 1 and 500") + } var out struct { Lines []string `json:"lines"` } - path := fmt.Sprintf("/api/runs/%s/tail?node=%s&lines=%d", runID, url.QueryEscape(nodeID), lines) + path := fmt.Sprintf("/api/runs/%s/tail?node=%s&lines=%d", url.PathEscape(runID), url.QueryEscape(nodeID), lines) err := c.do(ctx, http.MethodGet, path, nil, &out) return out.Lines, err } diff --git a/internal/tui/client_test.go b/internal/tui/client_test.go index 1036be9..3877528 100644 --- a/internal/tui/client_test.go +++ b/internal/tui/client_test.go @@ -3,6 +3,8 @@ package tui import ( "context" "encoding/json" + "fmt" + "net/http" "net/http/httptest" "path/filepath" "strings" @@ -306,3 +308,47 @@ func TestTailAgainstDaemon(t *testing.T) { } } } + +func TestTailRejectsInvalidLineCount(t *testing.T) { + client := NewClient("http://unused.invalid", "") + for _, lines := range []int{0, -1, 501} { + if _, err := client.Tail(context.Background(), "run", "node", lines); err == nil { + t.Fatalf("Tail accepted lines=%d", lines) + } + } +} + +func TestStreamEventsUsesCursorAndRawFrames(t *testing.T) { + var gotPath, gotAccept, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + gotAccept = r.Header.Get("Accept") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + fmt.Fprint(w, ": ping\n\n") + fmt.Fprint(w, "id: 8\n") + fmt.Fprint(w, `data: {"seq":8,"runID":"run id","nodeID":"w1","type":"transition","from":"running","to":"blocked","payload":{"reason":"permission","permissionID":"p1"},"createdAt":123}`+"\n\n") + fmt.Fprint(w, "id: 8\n") // duplicate must be ignored + fmt.Fprint(w, `data: {"seq":8,"runID":"run id","nodeID":"w1","type":"transition"}`+"\n\n") + })) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "secret") + var events []EventView + err := client.StreamEvents(context.Background(), "run id", 7, func(event EventView) error { + events = append(events, event) + return nil + }) + if err == nil || err.Error() != "EOF" { + t.Fatalf("closed stream err = %v, want EOF", err) + } + if gotPath != "/api/runs/run%20id/events?after=7" { + t.Fatalf("stream path = %q", gotPath) + } + if gotAccept != "text/event-stream" || gotAuth != "Bearer secret" { + t.Fatalf("stream headers accept=%q auth=%q", gotAccept, gotAuth) + } + if len(events) != 1 || events[0].Seq != 8 || events[0].To != "blocked" || string(events[0].Payload) == "" { + t.Fatalf("events = %+v", events) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f4a0a7a..23008c4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,16 +21,35 @@ type fetchMsg struct { } type fetchRunMsg struct { + runID string detail *RunDetail err error } type tailMsg struct { + runID string node string lines []string err error } +type eventStreamItem struct { + event *EventView + err error +} + +type eventStreamReadyMsg struct { + runID string + items <-chan eventStreamItem + cancel context.CancelFunc +} + +type eventStreamItemMsg struct { + runID string + items <-chan eventStreamItem + item eventStreamItem +} + type actionMsg struct { label string err error @@ -68,12 +87,19 @@ type Model struct { tailNode string tail []string + // Durable event stream state. eventCursor is the greatest sequence + // incorporated into detail. Full polling is used only while this stream + // is unavailable, plus the initial/action refreshes. + eventCursor int64 + streamRun string + streamItems <-chan eventStreamItem + streamCancel context.CancelFunc + streamConnecting bool + streamAttempted bool + // prevStates records the last seen node states so attention only // fires on transitions (gate awaiting approval, node failed). prevStates map[string]string - // notified remembers which attention conditions have already been - // announced, keyed by runID/nodeID/condition. - notified map[string]bool // notify delivers terminal attention; overridden in tests. notify func(title, body string) @@ -106,16 +132,53 @@ func fetchRunsCmd(m *Model) tea.Cmd { } func fetchRunCmd(m *Model) tea.Cmd { + runID := m.selectedID return func() tea.Msg { - d, err := m.api.GetRun(m.ctx, m.selectedID) - return fetchRunMsg{detail: d, err: err} + d, err := m.api.GetRun(m.ctx, runID) + return fetchRunMsg{runID: runID, detail: d, err: err} } } func fetchTailCmd(m *Model) tea.Cmd { + runID, node := m.selectedID, m.tailNode + return func() tea.Msg { + lines, err := m.api.Tail(m.ctx, runID, node, 40) + return tailMsg{runID: runID, node: node, lines: lines, err: err} + } +} + +func startEventStreamCmd(m *Model) tea.Cmd { + api, parent := m.api, m.ctx + runID, after := m.selectedID, m.eventCursor return func() tea.Msg { - lines, err := m.api.Tail(m.ctx, m.selectedID, m.tailNode, 40) - return tailMsg{node: m.tailNode, lines: lines, err: err} + ctx, cancel := context.WithCancel(parent) + items := make(chan eventStreamItem, 64) + go func() { + err := api.StreamEvents(ctx, runID, after, func(event EventView) error { + select { + case items <- eventStreamItem{event: &event}: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + select { + case items <- eventStreamItem{err: err}: + case <-ctx.Done(): + } + close(items) + }() + return eventStreamReadyMsg{runID: runID, items: items, cancel: cancel} + } +} + +func waitEventStreamCmd(runID string, items <-chan eventStreamItem) tea.Cmd { + return func() tea.Msg { + item, ok := <-items + if !ok { + item.err = fmt.Errorf("event stream closed") + } + return eventStreamItemMsg{runID: runID, items: items, item: item} } } @@ -136,18 +199,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tickMsg: m.lastFetch = time.Time(v) - cmd := tickCmd(m.tick) + cmds := []tea.Cmd{tickCmd(m.tick)} switch m.mode { case modeList: - return m, tea.Batch(cmd, fetchRunsCmd(m)) + cmds = append(cmds, fetchRunsCmd(m)) case modeDetail, modeInspect: - cmds := []tea.Cmd{cmd, fetchRunCmd(m)} + if m.detail == nil || (!m.detail.Done && m.streamItems == nil && !m.streamConnecting) { + // Initial/full refresh and fallback polling while SSE is down. + cmds = append(cmds, fetchRunCmd(m)) + if m.detail != nil && !m.detail.Done { + m.streamConnecting = true + m.streamAttempted = true + cmds = append(cmds, startEventStreamCmd(m)) + } + } if m.mode == modeInspect && m.tailNode != "" { cmds = append(cmds, fetchTailCmd(m)) } - return m, tea.Batch(cmds...) } - return m, cmd + return m, tea.Batch(cmds...) case fetchMsg: if v.err != nil { @@ -162,26 +232,99 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case fetchRunMsg: + if v.runID != "" && v.runID != m.selectedID { + return m, nil + } if v.err != nil || v.detail == nil { if v.err != nil { m.err = v.err } } else { + initial := m.detail == nil || m.detail.RunID != v.detail.RunID m.detail = v.detail m.err = nil + for _, event := range m.detail.Events { + if event.Seq > m.eventCursor { + m.eventCursor = event.Seq + } + } if m.nodeCursor >= len(m.detail.Graph.Nodes) { m.nodeCursor = 0 } - m.checkAttention() + var cmds []tea.Cmd + if initial { + m.seedAttentionStates() + } else if cmd := m.checkAttention(); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.detail.Done && m.streamItems == nil && !m.streamConnecting && !m.streamAttempted { + m.streamConnecting = true + m.streamAttempted = true + cmds = append(cmds, startEventStreamCmd(m)) + } + return m, tea.Batch(cmds...) } return m, nil case tailMsg: - if v.err == nil && v.node == m.tailNode { + if v.err == nil && v.runID == m.selectedID && v.node == m.tailNode { m.tail = v.lines } return m, nil + case eventStreamReadyMsg: + if v.runID != m.selectedID || (m.mode != modeDetail && m.mode != modeInspect) { + v.cancel() + return m, nil + } + if m.streamCancel != nil { + m.streamCancel() + } + m.streamRun = v.runID + m.streamItems = v.items + m.streamCancel = v.cancel + m.streamConnecting = false + return m, waitEventStreamCmd(v.runID, v.items) + + case eventStreamItemMsg: + if v.runID != m.selectedID || v.items != m.streamItems { + return m, nil + } + if v.item.err != nil { + m.stopEventStream() + m.status = "event stream unavailable; polling" + if m.detail != nil && !m.detail.Done { + return m, fetchRunCmd(m) + } + return m, nil + } + if v.item.event == nil { + return m, waitEventStreamCmd(v.runID, v.items) + } + event := *v.item.event + if event.RunID != "" && event.RunID != m.selectedID { + m.stopEventStream() + m.status = "event stream mismatched run; polling" + return m, fetchRunCmd(m) + } + if event.Seq <= m.eventCursor { + return m, waitEventStreamCmd(v.runID, v.items) + } + if m.eventCursor > 0 && event.Seq != m.eventCursor+1 { + m.stopEventStream() + m.status = "event stream gap; polling" + return m, fetchRunCmd(m) + } + needsRefresh := m.applyEvent(event) + cmds := []tea.Cmd{waitEventStreamCmd(v.runID, v.items)} + if cmd := m.checkAttention(); cmd != nil { + cmds = append(cmds, cmd) + } + if needsRefresh { + cmds = append(cmds, fetchRunCmd(m)) + } + return m, tea.Batch(cmds...) + case actionMsg: m.status = v.label if v.err != nil { @@ -221,9 +364,13 @@ func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.mode { case modeList: if m.cursor < len(m.runs) { + m.stopEventStream() m.selectedID = m.runs[m.cursor].ID m.mode = modeDetail m.nodeCursor = 0 + m.detail = nil + m.eventCursor = 0 + m.streamAttempted = false return m, fetchRunCmd(m) } case modeSteer: @@ -252,7 +399,7 @@ func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.tailNode = id m.tail = nil m.mode = modeInspect - return m, nil + return m, fetchTailCmd(m) } case "a": return m, m.nodeAction("approved", m.api.Approve) @@ -303,12 +450,12 @@ func (m *Model) nodeAction(label string, fn func(context.Context, string, string // pendingPermission returns the permission id the node is currently blocked // on, if its latest blocked transition carried a permission request. func (m *Model) pendingPermission(nodeID string) (string, bool) { - if m.detail == nil { + if m.detail == nil || m.detail.States[nodeID] != "blocked" { return "", false } - pid := "" - for _, ev := range m.detail.Events { - if ev.NodeID != nodeID || ev.To != "blocked" || len(ev.Payload) == 0 { + for i := len(m.detail.Events) - 1; i >= 0; i-- { + ev := m.detail.Events[i] + if ev.NodeID != nodeID || ev.Type != "transition" || ev.To != "blocked" { continue } var p struct { @@ -316,13 +463,13 @@ func (m *Model) pendingPermission(nodeID string) (string, bool) { PermissionID string `json:"permissionID"` } if json.Unmarshal(ev.Payload, &p) == nil && p.Reason == "permission" && p.PermissionID != "" { - pid = p.PermissionID + return p.PermissionID, true } - } - if pid == "" { + // Only the latest transition into blocked describes why the + // current blocked state exists. Never fall back to an older request. return "", false } - return pid, true + return "", false } // permissionAction answers the pending permission of the node under the @@ -352,8 +499,11 @@ func (m *Model) back() { case modeSteer: m.mode = modeDetail case modeDetail: + m.stopEventStream() m.mode = modeList m.detail = nil + m.eventCursor = 0 + m.streamAttempted = false } } @@ -372,6 +522,8 @@ func (m *Model) move(delta int) { if m.mode == modeInspect { if id, ok := m.nodeAt(m.nodeCursor); ok { m.inspectNode = id + m.tailNode = id + m.tail = nil } } } @@ -386,6 +538,13 @@ func (m *Model) moveTo(idx int) { return } m.nodeCursor = clamp(idx, 0, len(m.detail.Graph.Nodes)-1) + if m.mode == modeInspect { + if id, ok := m.nodeAt(m.nodeCursor); ok { + m.inspectNode = id + m.tailNode = id + m.tail = nil + } + } } } @@ -405,43 +564,106 @@ func (m *Model) SelectedNode() (string, bool) { return m.nodeAt(m.nodeCursor) } // CurrentRun returns the selected run id (for tests). func (m *Model) CurrentRun() string { return m.selectedID } +func (m *Model) stopEventStream() { + if m.streamCancel != nil { + m.streamCancel() + } + m.streamRun = "" + m.streamItems = nil + m.streamCancel = nil + m.streamConnecting = false +} + +func (m *Model) seedAttentionStates() { + if m.detail == nil { + return + } + if m.prevStates == nil { + m.prevStates = map[string]string{} + } + for _, node := range m.detail.Graph.Nodes { + m.prevStates[m.detail.RunID+"/"+node.ID] = m.detail.States[node.ID] + } +} + +// applyEvent incrementally materializes the state carried by one raw +// durable store.Event. It returns true when a full detail refresh is +// useful for data not present in the event (attempt rows or graph edits). +func (m *Model) applyEvent(event EventView) bool { + if m.detail == nil { + return true + } + m.eventCursor = event.Seq + m.detail.Events = append(m.detail.Events, event) + switch event.Type { + case "transition", "recovery": + if event.NodeID != "" && event.To != "" { + if m.detail.States == nil { + m.detail.States = map[string]string{} + } + m.detail.States[event.NodeID] = event.To + } + return false + case "run": + var payload struct { + Status string `json:"status"` + } + if json.Unmarshal(event.Payload, &payload) == nil && payload.Status != "" { + m.detail.Status = payload.Status + m.detail.Done = payload.Status != "active" && payload.Status != "created" + } + // Attempt finalization is durable before the terminal/waiting run + // event. Refresh once here so transcript metadata and usage settle. + return true + case "attempt", "verdict", "graph": + return true + default: + return false + } +} + // checkAttention fires a terminal attention notification when a node's // state demands it: a human gate awaiting approval (running) or any node // that fails. Each condition is announced once per transition. -func (m *Model) checkAttention() { - if m.detail == nil || m.notify == nil { - return +func (m *Model) checkAttention() tea.Cmd { + if m.detail == nil { + return nil } if m.prevStates == nil { m.prevStates = map[string]string{} } - if m.notified == nil { - m.notified = map[string]bool{} - } + var cmds []tea.Cmd for _, n := range m.detail.Graph.Nodes { cur := m.detail.States[n.ID] - prev := m.prevStates[n.ID] - m.prevStates[n.ID] = cur key := m.detail.RunID + "/" + n.ID - - // Drop remembered conditions that no longer hold, so a later - // recurrence (retry, re-opened gate) announces again. - if cur != "running" { - delete(m.notified, key+"/gate") - } - if cur != "failed" { - delete(m.notified, key+"/failed") + prev := m.prevStates[key] + m.prevStates[key] = cur + if m.notify == nil { + continue } - - if cur == "running" && prev != "running" && n.Type == "human_gate" && !m.notified[key+"/gate"] { - m.notified[key+"/gate"] = true - m.notify("corral: gate awaits approval", - fmt.Sprintf("gate %s on run %s awaits approval", n.ID, m.detail.RunID)) + var title, body string + switch { + case cur == "running" && prev != "running" && n.Type == "human_gate": + title = "corral: gate awaits approval" + body = fmt.Sprintf("gate %s on run %s awaits approval", n.ID, m.detail.RunID) + case cur == "failed" && prev != "failed": + title = "corral: node failed" + body = fmt.Sprintf("node %s on run %s failed", n.ID, m.detail.RunID) } - if cur == "failed" && prev != "failed" && !m.notified[key+"/failed"] { - m.notified[key+"/failed"] = true - m.notify("corral: node failed", - fmt.Sprintf("node %s on run %s failed", n.ID, m.detail.RunID)) + if title != "" { + notify := m.notify + titleCopy, bodyCopy := title, body + cmds = append(cmds, func() tea.Msg { + notify(titleCopy, bodyCopy) + return nil + }) } } + if len(cmds) == 1 { + return cmds[0] + } + if len(cmds) > 1 { + return tea.Batch(cmds...) + } + return nil } diff --git a/internal/tui/notify.go b/internal/tui/notify.go index 597d76c..859152b 100644 --- a/internal/tui/notify.go +++ b/internal/tui/notify.go @@ -1,21 +1,39 @@ package tui import ( + "context" "fmt" "os" "os/exec" "runtime" + "strings" + "time" + "unicode/utf8" ) +const notificationLimit = 512 + // NotifyAttention raises terminal attention when the run needs a human: // a terminal bell, plus a desktop notification on macOS (osascript) and // Linux (notify-send). Best-effort and non-blocking; failures are ignored. func NotifyAttention(title, body string) { ringBell() - cmd := desktopNotify(title, body) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + cmd := desktopNotify(ctx, boundNotification(title), boundNotification(body)) if cmd != nil { - _ = cmd.Start() + if err := cmd.Start(); err != nil { + cancel() + return + } + // Reap the child and release the timeout asynchronously. NotifyAttention + // itself is already run as a Bubble Tea command, never from Update. + go func() { + _ = cmd.Wait() + cancel() + }() + return } + cancel() } // ringBell writes a terminal bell (BEL) to stdout. This is safe inside a @@ -27,13 +45,34 @@ func ringBell() { // desktopNotify returns the platform notification command, or nil when the // platform has no supported notifier. -func desktopNotify(title, body string) *exec.Cmd { +func desktopNotify(ctx context.Context, title, body string) *exec.Cmd { switch runtime.GOOS { case "darwin": - return exec.Command("osascript", "-e", - fmt.Sprintf(`display notification %q with title %q`, body, title)) + // Pass untrusted text as argv, outside the AppleScript source. No + // quoting or escaping can turn it into executable AppleScript. + return exec.CommandContext(ctx, "osascript", "-e", + `on run argv +display notification (item 2 of argv) with title (item 1 of argv) +end run`, title, body) case "linux": - return exec.Command("notify-send", title, body) + return exec.CommandContext(ctx, "notify-send", "--", title, body) } return nil } + +func boundNotification(s string) string { + s = strings.Map(func(r rune) rune { + if r == 0 || (r < 32 && r != '\n' && r != '\t') { + return -1 + } + return r + }, s) + if len(s) > notificationLimit { + s = s[:notificationLimit] + // Avoid returning malformed UTF-8 after byte bounding. + for !utf8.ValidString(s) { + s = s[:len(s)-1] + } + } + return s +} diff --git a/internal/tui/notify_test.go b/internal/tui/notify_test.go new file mode 100644 index 0000000..d1ab919 --- /dev/null +++ b/internal/tui/notify_test.go @@ -0,0 +1,39 @@ +package tui + +import ( + "context" + "strings" + "testing" + "unicode/utf8" +) + +func TestDesktopNotificationDoesNotEmbedInputInAppleScript(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + injection := `" & do shell script "touch /tmp/pwned" & "` + cmd := desktopNotify(ctx, injection, injection) + if cmd == nil { + // Unsupported host. Construction is platform-dependent. + return + } + if len(cmd.Args) < 4 { + t.Fatalf("notification command args: %q", cmd.Args) + } + script := cmd.Args[2] + if strings.Contains(script, injection) || strings.Contains(script, "touch /tmp/pwned") { + t.Fatalf("untrusted input embedded in program source: %q", script) + } + if cmd.Args[len(cmd.Args)-2] != injection || cmd.Args[len(cmd.Args)-1] != injection { + t.Fatalf("notification text not passed as data argv: %q", cmd.Args) + } +} + +func TestBoundNotificationIsBoundedAndValidUTF8(t *testing.T) { + got := boundNotification(strings.Repeat("🙂", notificationLimit)) + if len(got) > notificationLimit { + t.Fatalf("notification = %d bytes, limit %d", len(got), notificationLimit) + } + if !utf8.ValidString(got) { + t.Fatal("notification truncation broke UTF-8") + } +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index af06fac..b066634 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "sync" "testing" "time" @@ -12,13 +13,16 @@ import ( ) type fakeAPI struct { - runs []RunSummary - detail *RunDetail - actions []string - listErr error - detailErr error - tailErr error - tail []string + mu sync.Mutex + runs []RunSummary + detail *RunDetail + actions []string + listErr error + detailErr error + tailErr error + tail []string + stream func(context.Context, string, int64, func(EventView) error) error + streamAfter []int64 } func (f *fakeAPI) ListRuns(ctx context.Context) ([]RunSummary, error) { @@ -35,6 +39,23 @@ func (f *fakeAPI) GetRun(ctx context.Context, runID string) (*RunDetail, error) return f.detail, nil } +func (f *fakeAPI) StreamEvents(ctx context.Context, runID string, after int64, emit func(EventView) error) error { + f.mu.Lock() + f.streamAfter = append(f.streamAfter, after) + stream := f.stream + f.mu.Unlock() + if stream != nil { + return stream(ctx, runID, after, emit) + } + return fmt.Errorf("stream unavailable") +} + +func (f *fakeAPI) streamCursors() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.streamAfter...) +} + func (f *fakeAPI) Tail(ctx context.Context, runID, nodeID string, lines int) ([]string, error) { if f.tailErr != nil { return nil, f.tailErr @@ -100,6 +121,19 @@ func send(t *testing.T, m *Model, msg tea.Msg) { } } +func runCmd(t *testing.T, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + return + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, nested := range batch { + runCmd(t, nested) + } + } +} + func TestListAndNavigation(t *testing.T) { api := &fakeAPI{runs: []RunSummary{{ID: "run_1", Status: "active", States: map[string]string{"w1": "running"}}}, detail: sampleDetail()} m := New(api, context.Background()) @@ -230,6 +264,49 @@ func TestPermissionRespond(t *testing.T) { } } +func TestResolvedPermissionIsNotExposedOrResent(t *testing.T) { + d := sampleDetail() + d.States["w1"] = "done" + d.Events = append(d.Events, EventView{ + Seq: 2, NodeID: "w1", Type: "transition", From: "running", To: "blocked", + Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-old"}`), + }) + d.Events = append(d.Events, EventView{ + Seq: 3, NodeID: "w1", Type: "transition", From: "blocked", To: "running", + }) + + api := &fakeAPI{} + m := New(api, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", d, modeDetail + if pid, ok := m.pendingPermission("w1"); ok || pid != "" { + t.Fatalf("resolved permission exposed: %q, %v", pid, ok) + } + if strings.Contains(m.View(), "perm-old") { + t.Fatalf("resolved permission rendered:\n%s", m.View()) + } + send(t, m, key("p")) + send(t, m, key("d")) + if len(api.actions) != 0 { + t.Fatalf("resolved permission resent: %v", api.actions) + } +} + +func TestOnlyLatestBlockedTransitionCanExposePermission(t *testing.T) { + d := sampleDetail() + d.States["w1"] = "blocked" + d.Events = append(d.Events, + EventView{Seq: 2, NodeID: "w1", Type: "transition", From: "running", To: "blocked", + Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-old"}`)}, + EventView{Seq: 3, NodeID: "w1", Type: "transition", From: "ready", To: "blocked", + Payload: json.RawMessage(`{"reason":"dependency_failed"}`)}, + ) + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", d, modeDetail + if pid, ok := m.pendingPermission("w1"); ok || pid != "" { + t.Fatalf("stale permission exposed after newer block: %q, %v", pid, ok) + } +} + func TestEmptyState(t *testing.T) { m := New(&fakeAPI{}, context.Background()) m.Update(fetchMsg{}) @@ -250,7 +327,7 @@ func TestLiveAttemptTail(t *testing.T) { t.Fatalf("inspect mode = %d tailNode=%q", m.mode, m.tailNode) } // Simulate the tail fetch result. - m.Update(tailMsg{node: "gate", lines: api.tail}) + m.Update(tailMsg{runID: "run_1", node: "gate", lines: api.tail}) view := m.View() for _, want := range []string{"live tail", "alpha", "beta", "gamma"} { if !strings.Contains(view, want) { @@ -271,15 +348,21 @@ func TestAttentionOnGateAndFailure(t *testing.T) { m.notify = func(title, body string) { got = append(got, title+" | "+body) } m.selectedID = "run_1" m.detail = sampleDetail() - - // First fetch: gate is running → gate attention fires. - m.Update(fetchRunMsg{detail: m.detail}) + m.detail.States["gate"] = "pending" + m.seedAttentionStates() + m.streamAttempted = true + + // Gate transitions into running → gate attention fires. + next := sampleDetail() + _, cmd := m.Update(fetchRunMsg{detail: next}) + runCmd(t, cmd) if len(got) != 1 || !strings.Contains(got[0], "gate") { t.Fatalf("gate attention not fired: %v", got) } // Same state again: no duplicate. - m.Update(fetchRunMsg{detail: m.detail}) + _, cmd = m.Update(fetchRunMsg{detail: next}) + runCmd(t, cmd) if len(got) != 1 { t.Fatalf("duplicate attention fired: %v", got) } @@ -287,7 +370,8 @@ func TestAttentionOnGateAndFailure(t *testing.T) { // A node fails → failure attention fires. detail := sampleDetail() detail.States["w1"] = "failed" - m.Update(fetchRunMsg{detail: detail}) + _, cmd = m.Update(fetchRunMsg{detail: detail}) + runCmd(t, cmd) if len(got) != 2 || !strings.Contains(got[1], "failed") { t.Fatalf("failure attention not fired: %v", got) } @@ -298,8 +382,11 @@ func TestAttentionDisabledByDefault(t *testing.T) { m := New(api, context.Background()) m.selectedID = "run_1" m.detail = sampleDetail() - m.Update(fetchRunMsg{detail: m.detail}) - if m.notified["run_1/gate/gate"] { + m.detail.States["gate"] = "pending" + m.seedAttentionStates() + m.streamAttempted = true + _, cmd := m.Update(fetchRunMsg{detail: sampleDetail()}) + if cmd != nil { t.Fatal("attention should be disabled unless EnableAttention is called") } } @@ -321,6 +408,141 @@ func TestBudgetBarInDetail(t *testing.T) { } } +func TestBudgetBarUsesHighestUtilizationAndDoesNotFillDone(t *testing.T) { + d := sampleDetail() + n := &d.Graph.Nodes[0] + n.Budget.MaxDuration = int64(100 * time.Second) + n.Budget.MaxTokens = 100 + n.Budget.MaxCost = 10 + d.Attempts["w1"] = []AttemptView{{ + ID: "run_1/w1/1", No: 1, Status: "done", + StartedAt: int64Ptr(1_000), FinishedAt: int64Ptr(11_000), + Tokens: 75, Cost: 2, + }} + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", d, modeDetail + bar := m.nodeBudgetBar(*n, d.Attempts["w1"], "done") + if !strings.Contains(bar, "tokens") { + t.Fatalf("dominant token budget not labeled: %q", bar) + } + if got := strings.Count(bar, "█"); got != 6 { + t.Fatalf("done node filled %d/8 cells, want actual 75%% (6/8): %q", got, bar) + } +} + +func TestDetailViewHandlesEmptyGraph(t *testing.T) { + d := &RunDetail{RunID: "empty", States: map[string]string{}, Attempts: map[string][]AttemptView{}} + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "empty", d, modeDetail + if got := m.View(); !strings.Contains(got, "no nodes") { + t.Fatalf("empty graph view missing empty state:\n%s", got) + } +} + +func TestAttentionExecutesOutsideUpdate(t *testing.T) { + var got []string + m := New(&fakeAPI{}, context.Background()) + m.notify = func(title, body string) { got = append(got, title+" | "+body) } + m.selectedID = "run_1" + m.detail = sampleDetail() + m.detail.States["gate"] = "pending" + m.seedAttentionStates() + m.streamAttempted = true + _, cmd := m.Update(fetchRunMsg{detail: sampleDetail()}) + if len(got) != 0 { + t.Fatalf("notification blocked Update: %v", got) + } + if cmd == nil { + t.Fatal("transition did not return async attention command") + } + _ = cmd() + if len(got) != 1 || !strings.Contains(got[0], "gate") { + t.Fatalf("attention command did not notify: %v", got) + } +} + +func TestEventUpdatesStateIncrementallyAndAdvancesCursor(t *testing.T) { + d := sampleDetail() + d.States["w1"] = "running" + d.Events = []EventView{{Seq: 4, Type: "transition", NodeID: "w1", From: "leased", To: "running"}} + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode, m.eventCursor = "run_1", d, modeDetail, 4 + items := make(chan eventStreamItem) + m.streamItems = items + + event := EventView{Seq: 5, RunID: "run_1", Type: "transition", NodeID: "w1", From: "running", To: "blocked", + Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-live"}`)} + _, cmd := m.Update(eventStreamItemMsg{runID: "run_1", items: items, item: eventStreamItem{event: &event}}) + if m.eventCursor != 5 || m.detail.States["w1"] != "blocked" { + t.Fatalf("incremental state cursor=%d state=%q", m.eventCursor, m.detail.States["w1"]) + } + if pid, ok := m.pendingPermission("w1"); !ok || pid != "perm-live" { + t.Fatalf("incremental permission = %q, %v", pid, ok) + } + if cmd == nil { + t.Fatal("stream listener was not continued") + } +} + +func TestDroppedStreamFallsBackToFullRefreshAndReconnectsFromCursor(t *testing.T) { + api := &fakeAPI{detail: sampleDetail()} + m := New(api, context.Background()) + m.selectedID, m.detail, m.mode, m.eventCursor = "run_1", sampleDetail(), modeDetail, 9 + items := make(chan eventStreamItem) + m.streamItems = items + canceled := false + m.streamCancel = func() { canceled = true } + + _, cmd := m.Update(eventStreamItemMsg{runID: "run_1", items: items, item: eventStreamItem{err: fmt.Errorf("dropped")}}) + if !canceled || m.streamItems != nil || cmd == nil { + t.Fatalf("drop did not stop stream/fallback: canceled=%v items=%v cmd=%v", canceled, m.streamItems, cmd) + } + msg := cmd() + refresh, ok := msg.(fetchRunMsg) + if !ok || refresh.detail == nil || refresh.runID != "run_1" { + t.Fatalf("fallback = %#v, want full run refresh", msg) + } + + _, cmd = m.Update(tickMsg(time.Now())) + batch, ok := cmd().(tea.BatchMsg) + if !ok { + t.Fatalf("reconnect tick = %T, want batch", cmd()) + } + var ready eventStreamReadyMsg + for _, nested := range batch { + if nested == nil { + continue + } + if msg := nested(); msg != nil { + if value, ok := msg.(eventStreamReadyMsg); ok { + ready = value + } + } + } + if ready.items == nil { + t.Fatal("fallback tick did not reconnect stream") + } + ready.cancel() + deadline := time.Now().Add(time.Second) + for len(api.streamCursors()) == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + cursors := api.streamCursors() + if len(cursors) == 0 || cursors[len(cursors)-1] != 9 { + t.Fatalf("reconnect cursors = %v, want last cursor 9", cursors) + } +} + +func TestHealthyStreamPreventsOneSecondFullPolling(t *testing.T) { + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", sampleDetail(), modeDetail + m.streamItems = make(chan eventStreamItem) + _, cmd := m.Update(tickMsg(time.Now())) + if _, ok := cmd().(fetchRunMsg); ok { + t.Fatal("healthy event stream still triggered one-second full polling") + } +} + func int64Ptr(v int64) *int64 { return &v } func TestFetchErrorShown(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index de1a3d2..85c7e0c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -120,6 +120,9 @@ func (m *Model) viewDetail() string { // DAG view: nodes in dependency order, arrows between them. b.WriteString(styleDim.Render("dag") + "\n") nodes := m.detail.Graph.Nodes + if len(nodes) == 0 { + b.WriteString(styleMuted.Render(" no nodes") + "\n") + } byID := map[string]GraphNode{} for _, n := range nodes { byID[n.ID] = n @@ -131,9 +134,10 @@ func (m *Model) viewDetail() string { for _, n := range order { indeg[n.ID] = len(n.DependsOn) } + selected, _ := m.nodeAt(m.nodeCursor) for _, n := range order { line := " " + m.nodeLine(n, indeg[n.ID]) - if n.ID == m.detail.Graph.Nodes[m.nodeCursor].ID { + if n.ID == selected { line = styleSelected.Render(line) } b.WriteString(line + "\n") @@ -172,7 +176,7 @@ func (m *Model) nodeLine(n GraphNode, deps int) string { // nodeBudgetBar renders a compact budget-usage bar for a node. The // dominant budget dimension (time, tokens, or cost) drives the bar, with // the used/limit figures beside it. Nodes without a budget show "". -func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, state string) string { +func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, _ string) string { maxDur := n.Budget.MaxDuration maxTok := n.Budget.MaxTokens maxCost := n.Budget.MaxCost @@ -202,24 +206,38 @@ func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, state string) str usedTok += at.Tokens usedCost += at.Cost } - // Choose the dominant dimension. - var frac float64 - var label string - switch { - case maxDur > 0: - frac = float64(usedDur) / float64(time.Duration(maxDur)) - label = fmt.Sprintf("%s/%s", usedDur.Round(time.Second), time.Duration(maxDur).Round(time.Second)) - case maxTok > 0: - frac = float64(usedTok) / float64(maxTok) - label = fmt.Sprintf("%dk/%dk", usedTok/1000, maxTok/1000) - default: - frac = usedCost / maxCost - label = fmt.Sprintf("$%.2f/%.2f", usedCost, maxCost) - } - if state == "done" { - frac = 1 + // Choose the highest-utilization configured dimension. Completion is + // lifecycle progress, not budget consumption, so done nodes retain + // their actual utilization. + type usage struct { + fraction float64 + label string + } + var dominant usage + consider := func(candidate usage) { + if dominant.label == "" || candidate.fraction > dominant.fraction { + dominant = candidate + } } - return progressBar(frac, 8) + " " + styleMuted.Render(label) + if maxDur > 0 { + consider(usage{ + fraction: float64(usedDur) / float64(time.Duration(maxDur)), + label: fmt.Sprintf("time %s/%s", usedDur.Round(time.Second), time.Duration(maxDur).Round(time.Second)), + }) + } + if maxTok > 0 { + consider(usage{ + fraction: float64(usedTok) / float64(maxTok), + label: fmt.Sprintf("tokens %d/%d", usedTok, maxTok), + }) + } + if maxCost > 0 { + consider(usage{ + fraction: usedCost / maxCost, + label: fmt.Sprintf("cost $%.2f/$%.2f", usedCost, maxCost), + }) + } + return progressBar(dominant.fraction, 8) + " " + styleMuted.Render(dominant.label) } // now returns the model's wall-clock reference (last tick time, or real From e8455cdb88e729fe53ff4d970b8a86b8abfc887b Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:48:17 -0300 Subject: [PATCH 18/26] fix: stop TUI event stream on terminal runs --- internal/tui/model.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 23008c4..6f73fe5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -614,6 +614,9 @@ func (m *Model) applyEvent(event EventView) bool { } // Attempt finalization is durable before the terminal/waiting run // event. Refresh once here so transcript metadata and usage settle. + if m.detail.Done { + m.stopEventStream() + } return true case "attempt", "verdict", "graph": return true From c6ac506858ffdbc758b7628c9350293f810125c7 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:52:41 -0300 Subject: [PATCH 19/26] fix: keep waiting run telemetry connected --- internal/sched/sched.go | 10 ++++++++-- internal/sched/tail_test.go | 12 ++++++++++++ internal/tui/model.go | 14 +++++++++----- internal/tui/tui_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 5350935..c2ca233 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -979,9 +979,15 @@ func (h *RunHandle) Steer(ctx context.Context, id graph.NodeID, message string) } // Tail returns the last n transcript lines of the in-flight attempt of a -// node, for live output in the companion TUI. Empty when the node has no -// live session (e.g. inline check/gate nodes). +// node, for live output in the companion TUI. It reports an error when the +// node has no live session (e.g. inline check/gate nodes). func (h *RunHandle) Tail(ctx context.Context, id graph.NodeID, n int) ([]string, error) { + if id == "" { + return nil, fmt.Errorf("node required") + } + if n < 1 || n > 500 { + return nil, fmt.Errorf("lines must be between 1 and 500") + } h.mu.Lock() rec := h.sessions[id] if rec == nil { diff --git a/internal/sched/tail_test.go b/internal/sched/tail_test.go index 48d4f18..6ead317 100644 --- a/internal/sched/tail_test.go +++ b/internal/sched/tail_test.go @@ -63,3 +63,15 @@ func TestTailDoesNotHoldRunMutexWhileFetchingMessages(t *testing.T) { t.Fatal(err) } } + +func TestTailRejectsInvalidLineCount(t *testing.T) { + h := &RunHandle{} + if _, err := h.Tail(context.Background(), "", 100); err == nil { + t.Fatal("Tail accepted empty node ID") + } + for _, lines := range []int{-1, 0, 501} { + if _, err := h.Tail(context.Background(), "node", lines); err == nil { + t.Fatalf("Tail accepted lines=%d", lines) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 6f73fe5..163b263 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -204,10 +204,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case modeList: cmds = append(cmds, fetchRunsCmd(m)) case modeDetail, modeInspect: - if m.detail == nil || (!m.detail.Done && m.streamItems == nil && !m.streamConnecting) { + if m.detail == nil || (!m.runTerminal() && m.streamItems == nil && !m.streamConnecting) { // Initial/full refresh and fallback polling while SSE is down. cmds = append(cmds, fetchRunCmd(m)) - if m.detail != nil && !m.detail.Done { + if m.detail != nil && !m.runTerminal() { m.streamConnecting = true m.streamAttempted = true cmds = append(cmds, startEventStreamCmd(m)) @@ -257,7 +257,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if cmd := m.checkAttention(); cmd != nil { cmds = append(cmds, cmd) } - if !m.detail.Done && m.streamItems == nil && !m.streamConnecting && !m.streamAttempted { + if !m.runTerminal() && m.streamItems == nil && !m.streamConnecting && !m.streamAttempted { m.streamConnecting = true m.streamAttempted = true cmds = append(cmds, startEventStreamCmd(m)) @@ -293,7 +293,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if v.item.err != nil { m.stopEventStream() m.status = "event stream unavailable; polling" - if m.detail != nil && !m.detail.Done { + if m.detail != nil && !m.runTerminal() { return m, fetchRunCmd(m) } return m, nil @@ -574,6 +574,10 @@ func (m *Model) stopEventStream() { m.streamConnecting = false } +func (m *Model) runTerminal() bool { + return m.detail != nil && (m.detail.Status == "completed" || m.detail.Status == "canceled") +} + func (m *Model) seedAttentionStates() { if m.detail == nil { return @@ -614,7 +618,7 @@ func (m *Model) applyEvent(event EventView) bool { } // Attempt finalization is durable before the terminal/waiting run // event. Refresh once here so transcript metadata and usage settle. - if m.detail.Done { + if m.runTerminal() { m.stopEventStream() } return true diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b066634..aa38945 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -543,6 +543,38 @@ func TestHealthyStreamPreventsOneSecondFullPolling(t *testing.T) { } } +func TestWaitingRunKeepsStreamForExternalResolution(t *testing.T) { + d := sampleDetail() + d.Status = "waiting" + d.Done = true + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode, m.eventCursor = "run_1", d, modeDetail, 4 + items := make(chan eventStreamItem) + m.streamItems = items + canceled := false + m.streamCancel = func() { canceled = true } + event := EventView{Seq: 5, RunID: "run_1", Type: "run", Payload: json.RawMessage(`{"status":"waiting"}`)} + _, cmd := m.Update(eventStreamItemMsg{runID: "run_1", items: items, item: eventStreamItem{event: &event}}) + if canceled || m.streamItems == nil { + t.Fatal("waiting run stopped stream needed for later permission resolution") + } + if cmd == nil { + t.Fatal("waiting run did not keep stream listener") + } +} + +func TestInitialWaitingRunStartsEventStream(t *testing.T) { + d := sampleDetail() + d.Status = "waiting" + d.Done = true + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.mode = "run_1", modeDetail + _, cmd := m.Update(fetchRunMsg{runID: "run_1", detail: d}) + if cmd == nil || !m.streamConnecting { + t.Fatal("initial waiting run did not start event stream") + } +} + func int64Ptr(v int64) *int64 { return &v } func TestFetchErrorShown(t *testing.T) { From c1cf9deb7bebfd19d4cbc2390da213eb28ce71d7 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:59:38 -0300 Subject: [PATCH 20/26] fix: preserve monotonic live TUI state --- internal/sched/sched.go | 2 +- internal/sched/tail_test.go | 7 ++++ internal/tui/api.go | 6 ++-- internal/tui/client_test.go | 17 ++++++++++ internal/tui/model.go | 32 ++++++++++++++---- internal/tui/tui_test.go | 66 +++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 4 +-- 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/internal/sched/sched.go b/internal/sched/sched.go index c2ca233..fb139af 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -1012,7 +1012,7 @@ func (h *RunHandle) Tail(ctx context.Context, id graph.NodeID, n int) ([]string, // transcriptLines flattens a session transcript into text lines and keeps // only the last n of them (a "tail"). func transcriptLines(msgs []adapter.Message, n int) []string { - var lines []string + lines := make([]string, 0) for _, m := range msgs { for _, ln := range strings.Split(m.Text, "\n") { if ln == "" { diff --git a/internal/sched/tail_test.go b/internal/sched/tail_test.go index 6ead317..b2f2918 100644 --- a/internal/sched/tail_test.go +++ b/internal/sched/tail_test.go @@ -75,3 +75,10 @@ func TestTailRejectsInvalidLineCount(t *testing.T) { } } } + +func TestTranscriptLinesEmptyReturnsEmptySlice(t *testing.T) { + lines := transcriptLines(nil, 10) + if lines == nil || len(lines) != 0 { + t.Fatalf("transcriptLines(nil) = %#v, want non-nil empty slice", lines) + } +} diff --git a/internal/tui/api.go b/internal/tui/api.go index a169431..c4b6472 100644 --- a/internal/tui/api.go +++ b/internal/tui/api.go @@ -174,6 +174,9 @@ func (c *Client) StreamEvents(ctx context.Context, runID string, after int64, em if after < 0 { return fmt.Errorf("after must not be negative") } + if emit == nil { + return errors.New("event emitter required") + } path := fmt.Sprintf("/api/runs/%s/events?after=%d", url.PathEscape(runID), after) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil) if err != nil { @@ -233,9 +236,6 @@ func (c *Client) StreamEvents(ctx context.Context, runID string, after int64, em if event.Seq <= cursor { return nil } - if emit == nil { - return errors.New("event emitter required") - } if err := emit(event); err != nil { return err } diff --git a/internal/tui/client_test.go b/internal/tui/client_test.go index 3877528..6244c28 100644 --- a/internal/tui/client_test.go +++ b/internal/tui/client_test.go @@ -352,3 +352,20 @@ func TestStreamEventsUsesCursorAndRawFrames(t *testing.T) { t.Fatalf("events = %+v", events) } } + +func TestStreamEventsRejectsNilEmitterBeforeRequest(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "text/event-stream") + })) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "") + if err := client.StreamEvents(context.Background(), "run", 0, nil); err == nil { + t.Fatal("StreamEvents accepted nil emitter") + } + if requests != 0 { + t.Fatalf("nil emitter opened %d HTTP requests", requests) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 163b263..359471d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -241,12 +241,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else { initial := m.detail == nil || m.detail.RunID != v.detail.RunID + snapshotCursor := int64(0) + for _, event := range v.detail.Events { + if event.Seq > snapshotCursor { + snapshotCursor = event.Seq + } + } + // An action/fallback GET can race a healthy stream. Never let an + // older snapshot overwrite state already materialized from SSE. + if !initial && snapshotCursor < m.eventCursor { + return m, nil + } m.detail = v.detail m.err = nil - for _, event := range m.detail.Events { - if event.Seq > m.eventCursor { - m.eventCursor = event.Seq - } + if snapshotCursor > m.eventCursor { + m.eventCursor = snapshotCursor } if m.nodeCursor >= len(m.detail.Graph.Nodes) { m.nodeCursor = 0 @@ -310,13 +319,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if event.Seq <= m.eventCursor { return m, waitEventStreamCmd(v.runID, v.items) } - if m.eventCursor > 0 && event.Seq != m.eventCursor+1 { + if event.Seq != m.eventCursor+1 { m.stopEventStream() m.status = "event stream gap; polling" return m, fetchRunCmd(m) } needsRefresh := m.applyEvent(event) - cmds := []tea.Cmd{waitEventStreamCmd(v.runID, v.items)} + var cmds []tea.Cmd + // applyEvent stops the channel on a terminal run event. Only wait for + // another frame while this channel is still the active stream. + if m.streamItems == v.items { + cmds = append(cmds, waitEventStreamCmd(v.runID, v.items)) + } if cmd := m.checkAttention(); cmd != nil { cmds = append(cmds, cmd) } @@ -575,7 +589,11 @@ func (m *Model) stopEventStream() { } func (m *Model) runTerminal() bool { - return m.detail != nil && (m.detail.Status == "completed" || m.detail.Status == "canceled") + return m.detail != nil && terminalRunStatus(m.detail.Status) +} + +func terminalRunStatus(status string) bool { + return status == "completed" || status == "canceled" } func (m *Model) seedAttentionStates() { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index aa38945..03f8a7b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -484,6 +484,42 @@ func TestEventUpdatesStateIncrementallyAndAdvancesCursor(t *testing.T) { } } +func TestFirstStreamEventGapFallsBackWithoutApplying(t *testing.T) { + d := sampleDetail() + d.Events = nil + d.States["w1"] = "pending" + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", d, modeDetail + items := make(chan eventStreamItem) + m.streamItems = items + m.streamCancel = func() {} + event := EventView{Seq: 2, RunID: "run_1", Type: "transition", NodeID: "w1", From: "pending", To: "running"} + + _, cmd := m.Update(eventStreamItemMsg{runID: "run_1", items: items, item: eventStreamItem{event: &event}}) + if m.eventCursor != 0 || m.detail.States["w1"] != "pending" { + t.Fatalf("gapped event applied: cursor=%d state=%q", m.eventCursor, m.detail.States["w1"]) + } + if m.streamItems != nil || cmd == nil { + t.Fatal("gapped event did not stop stream and request full refresh") + } +} + +func TestStaleFullRefreshDoesNotOverwriteNewerEventState(t *testing.T) { + current := sampleDetail() + current.Events = []EventView{{Seq: 5, Type: "transition", NodeID: "w1", From: "leased", To: "running"}} + current.States["w1"] = "running" + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode, m.eventCursor = "run_1", current, modeDetail, 5 + + stale := sampleDetail() + stale.Events = []EventView{{Seq: 4, Type: "transition", NodeID: "w1", From: "ready", To: "leased"}} + stale.States["w1"] = "leased" + m.Update(fetchRunMsg{runID: "run_1", detail: stale}) + if m.detail.States["w1"] != "running" || m.eventCursor != 5 { + t.Fatalf("stale refresh regressed state: cursor=%d state=%q", m.eventCursor, m.detail.States["w1"]) + } +} + func TestDroppedStreamFallsBackToFullRefreshAndReconnectsFromCursor(t *testing.T) { api := &fakeAPI{detail: sampleDetail()} m := New(api, context.Background()) @@ -563,6 +599,25 @@ func TestWaitingRunKeepsStreamForExternalResolution(t *testing.T) { } } +func TestTerminalRunEventStopsWithoutAnotherStreamWait(t *testing.T) { + d := sampleDetail() + d.States["w1"] = "done" + m := New(&fakeAPI{detail: d}, context.Background()) + m.selectedID, m.detail, m.mode, m.eventCursor = "run_1", d, modeDetail, 4 + items := make(chan eventStreamItem) + m.streamItems = items + m.streamCancel = func() {} + event := EventView{Seq: 5, RunID: "run_1", Type: "run", Payload: json.RawMessage(`{"status":"completed"}`)} + + _, cmd := m.Update(eventStreamItemMsg{runID: "run_1", items: items, item: eventStreamItem{event: &event}}) + if m.streamItems != nil || !m.runTerminal() { + t.Fatal("terminal run event did not stop stream") + } + if _, ok := cmd().(tea.BatchMsg); ok { + t.Fatal("terminal event enqueued another wait on stopped stream") + } +} + func TestInitialWaitingRunStartsEventStream(t *testing.T) { d := sampleDetail() d.Status = "waiting" @@ -575,6 +630,17 @@ func TestInitialWaitingRunStartsEventStream(t *testing.T) { } } +func TestWaitingRunDoesNotRenderDoneMarker(t *testing.T) { + d := sampleDetail() + d.Status = "waiting" + d.Done = true + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.detail, m.mode = "run_1", d, modeDetail + if got := m.View(); strings.Contains(got, "✓ done") { + t.Fatalf("waiting run rendered terminal marker:\n%s", got) + } +} + func int64Ptr(v int64) *int64 { return &v } func TestFetchErrorShown(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index 85c7e0c..96e6080 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -84,7 +84,7 @@ func (m *Model) viewList() string { if i == m.cursor { line = styleSelected.Render(line) } - if r.Done { + if r.Status == "completed" { line += styleOK.Render(" ✓") } b.WriteString(line + "\n") @@ -100,7 +100,7 @@ func (m *Model) viewDetail() string { } var b strings.Builder b.WriteString(styleTitle.Render(fmt.Sprintf("run %s [%s]", m.detail.RunID, m.detail.Status))) - if m.detail.Done { + if m.detail.Status == "completed" { b.WriteString(styleOK.Render(" ✓ done")) } // Overall run progress: done nodes / total. From 6f5cae1b6b89fead299099b3ae9ccbb90a3064aa Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Wed, 12 Aug 2026 00:37:17 -0300 Subject: [PATCH 21/26] docs: align provider and streaming contracts --- README.md | 26 +++++++++++++++++--------- docs/task3-adapter.md | 19 ++++++++++++++++++- docs/task6-plugin.md | 13 ++++++++----- docs/task7-tui.md | 13 ++++++++++--- internal/adapter/adapter.go | 6 +++--- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c5ef50c..e5b19a6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Inside OpenCode: 3. Switch to `corral-orchestrator` and ask it to start that graph. 4. Follow progress with `corral_status` / `corral_watch`; approve, reject, retry, cancel, or steer nodes when needed. `corral_start` accepts an - optional `autoApproveGates` flag pre-authorizes the orchestrator to call + optional `autoApproveGates` flag that pre-authorizes the orchestrator to call the normal gate approval endpoint without waiting for the operator. Or follow the same run from the terminal: @@ -101,9 +101,12 @@ corral tui Corral TUI inspecting a completed attempt, its worktree, command gate, and exit evidence -The TUI exposes the graph, node states, attempts, sessions, worktrees, evidence, -and operator actions. Worker edits stay in attempt worktrees; initialization -itself may add the OpenCode tool and agent config to your checkout. +The TUI follows durable server-sent run events, falls back to polling after a +stream failure, and exposes graph state, live transcript tails, budget usage, +attempts, sessions, worktrees, evidence, permissions, and operator actions. It +can raise desktop attention when a gate needs approval or a node fails. Worker +edits stay in attempt worktrees; initialization itself may add the OpenCode tool +and agent config to your checkout. ## What counts as evidence @@ -160,8 +163,10 @@ evidence remain stored when an attempt retries. - **Landing:** merge nodes commit accepted worktree changes, merge branches with `--no-ff`, run their post-merge command, and prune consumed worktrees. -OpenCode is the implemented driver. The generic `adapter.Driver` interface is -the seam for future executors. +OpenCode is the production-wired driver. A self-contained Claude Code adapter +implements the same contract, including scoped permission mediation, but is not +yet selected by `corral daemon`. The generic `adapter.Driver` interface remains +the seam for additional executors. ## Operations @@ -226,6 +231,7 @@ The core packages are deliberately small: | `internal/verify` | command, JSON Schema, diff, and reviewer evidence | | `internal/worktree` | branch/worktree lifecycle and diff artifacts | | `internal/ocxadapter` | OpenCode sessions and completion reconciliation | +| `internal/claudeadapter` | standalone Claude Code sessions, usage, and permission mediation | | `internal/ocxreviewer` | OpenCode reviewer sessions for the reviewer gate | | `internal/daemon` | control API, planning, role routing, audit export | | `internal/tui` | terminal dashboard and operator controls | @@ -235,9 +241,11 @@ Visual language, color roles, and asset rules live in the ## Scope -Corral is currently local, single-machine, single-repository software with one -implemented executor: OpenCode. Distributed workers, Codex/Claude drivers, -interactive graph editing, and a web dashboard remain roadmap work. +Corral is currently local, single-machine, single-repository software. OpenCode +is the production-wired executor; the Claude Code adapter is available as a +self-contained package but has no daemon selection/configuration path yet. +Distributed workers, a Codex driver, interactive graph editing, and a web +dashboard remain roadmap work. ## License diff --git a/docs/task3-adapter.md b/docs/task3-adapter.md index 1c4213e..dabfbb2 100644 --- a/docs/task3-adapter.md +++ b/docs/task3-adapter.md @@ -1,4 +1,4 @@ -# Task 3 — OpenCode adapter +# Task 3 — Provider adapters Status: **DONE** — the generic adapter contract is mapped onto real OpenCode sessions; integration test covers parallel execution and cancellation. @@ -31,6 +31,20 @@ sessions; integration test covers parallel execution and cancellation. `ServerID` per attempt (`store.Attempt.ServerID`, new `server_id` column); new `RunHandle.CancelNode` (operator cancel → driver abort → `running → canceled`). +- `internal/claudeadapter` — a self-contained Claude Code implementation of + `adapter.Driver`, `adapter.Stepper`, and permission-aware sessions: + - launches one headless `claude -p --output-format stream-json` process per + attempt and reconciles its ordered transcript with process exit; + - records cumulative result usage/cost once per attempt and emits one + completion even when terminal signals repeat; + - mediates permission requests through a private local Unix socket and an + MCP stdio helper, validating attempt/session/request identity before a + decision is delivered; + - supports scoped read/write tool rules, model overrides, aborts, oversized + stream records, and high-volume event streams. + + This package is deliberately not wired into `cmd/corral`; OpenCode remains + the daemon's selected executor until provider selection/configuration lands. ## Acceptance verification @@ -41,6 +55,7 @@ sessions; integration test covers parallel execution and cancellation. | Event stream + status polling fallback | Both paths implemented; polling is the completion path when events are dropped (same `maybeComplete` logic) | | Duplicate/missing events cannot duplicate completion | Exactly 1 attempt row per node asserted; `completed` guard + scheduler drop of unknown/duplicate attempt results | | Two-node parallel run + cancellation test | Both sessions observed busy concurrently (peak ≥ 2); w1 done with real file output; w2 canceled with `aborted` attempt | +| Claude protocol and permission mediation | Protocol fixtures cover init/result/usage, large and high-volume streams, duplicate terminal events, scoped MCP decisions, aborts, and closed-driver behavior; race and cross-build checks cover the package | ## Notes @@ -49,3 +64,5 @@ sessions; integration test covers parallel execution and cancellation. - Watchers exit via attempt cancel / driver `Close`; the shared stream goroutine runs until `Close`. - Run: `go test ./internal/ocxadapter -run TestOpenCodeAdapterParallelAndCancel -v` +- Claude's package tests are deterministic; a live Claude run remains an + explicit provider-gated check rather than part of the default suite. diff --git a/docs/task6-plugin.md b/docs/task6-plugin.md index 83f9f79..f5aa36d 100644 --- a/docs/task6-plugin.md +++ b/docs/task6-plugin.md @@ -19,9 +19,12 @@ flow is verified end-to-end against a real OpenCode server. authorizes the orchestrator to call the normal approval endpoint. - `GET /api/runs`, `GET /api/runs/{id}` — follow execution (states, attempts, event log). - - `GET /api/runs/{id}/watch` — Server-Sent Events stream of run deltas - (node transitions, gates awaiting approval, run done) from an `after` - cursor; powers `corral_watch`. + - `GET /api/runs/{id}/watch` — bounded JSON long-poll of run deltas from a + `since` cursor (node transitions, gates awaiting approval, run done); + powers `corral_watch`. + - `GET /api/runs/{id}/events` — raw durable Server-Sent Events stream from + an `after` cursor, with replay, live delivery, heartbeat frames, and + terminal close; powers streaming clients such as the companion TUI. - `approve` / `reject` / `cancel` / `retry` / `steer` per node — including `RetryNode` (blocked→ready, retry_wait→ready, failed→ready operator override with retry budget reset) and run-loop restart after @@ -37,8 +40,8 @@ flow is verified end-to-end against a real OpenCode server. `corral_start` (accepts the raw graph *or* the full `corral_plan` output, unwrapping a leading `{"graph": ...}` wrapper, plus an optional `autoApproveGates` flag), `corral_status`, `corral_watch` (blocks on the - daemon SSE stream and returns the first run delta — node transition, gate - awaiting approval, or run done — or times out), `corral_approve`, + daemon long-poll endpoint and returns the first run delta — node transition, + gate awaiting approval, or run done — or times out), `corral_approve`, `corral_reject`, `corral_cancel`, `corral_retry`, `corral_steer`, calling the daemon and mapping the session agent (`corral-*`) to a role. - `example/opencode.json` — agent role configuration using OpenCode's diff --git a/docs/task7-tui.md b/docs/task7-tui.md index 935b82c..7d0b875 100644 --- a/docs/task7-tui.md +++ b/docs/task7-tui.md @@ -8,7 +8,8 @@ is the companion observability surface (no OpenCode fork needed). ## Deliverables - `internal/tui/api.go` — HTTP client + DTOs over the daemon endpoints - (`ListRuns`, `GetRun`, `Approve`, `Reject`, `Cancel`, `Retry`, `Steer`). + (`ListRuns`, `GetRun`, durable `StreamEvents`, live `Tail`, `Approve`, + `Reject`, `Cancel`, `Retry`, `Steer`, and permission responses). - `internal/tui/model.go` — bubbletea model. All state changes happen in `Update` (tick-driven fetch, keys), so it is fully testable without a terminal. Modes: list → detail → inspect → steer. Keys: @@ -16,7 +17,7 @@ is the companion observability surface (no OpenCode fork needed). - detail: `↑/↓` node, `a` approve, `r` reject, `c` cancel, `t` retry, `s` steer (typed message, enter sends), `i` inspect, `esc` back - inspect: attempts (status, session, worktree, elapsed, cost/tokens, - evidence), `esc` back + evidence) plus the active attempt's live transcript tail, `esc` back - `internal/tui/view.go` — lipgloss rendering: - runs list: id, status, per-node state chips (`w1:done gate:running`) - DAG view: nodes sorted with dependency arrows (`← dep (state)`), @@ -25,6 +26,8 @@ is the companion observability surface (no OpenCode fork needed). - inspect: objective, role, write scope, verification, attempt rows with session/worktree paths and evidence snippets - status line with keybinding help, last action, and errors +- `internal/tui/notify.go` — bounded, non-blocking terminal/desktop attention + for gates awaiting approval and node failures. - `cmd/corral tui` — connects to `CORRAL_DAEMON_URL` (default `http://127.0.0.1:4519`) with optional bearer key, alt-screen program. @@ -37,11 +40,15 @@ is the companion observability surface (no OpenCode fork needed). | Active agent/session/worktree | inspect shows session id + worktree path per attempt | | Attempts, elapsed time, budget, evidence | inspect rows: attempt #, status, elapsed, cost/tokens, evidence snippet (budget visible in run status) | | Inspect, steer, retry, cancel actions | `TestNodeActions` drives every key path through the model; `TestClientAgainstDaemon` round-trips all actions against a live daemon | +| Durable live updates and recovery | SSE client/model tests cover replay cursors, ordered incremental state, disconnect/gap fallback, terminal close, waiting-run continuation, and stale-response protection | +| Live output, budgets, attention | Tail endpoint/client/model tests, highest-utilization budget bars, and async bounded notification tests | ## Notes - The model executes commands like the tea runtime (`send` helper in tests) so no terminal is needed for coverage. -- Actions refresh immediately after execution; the view polls every 1s. +- Actions refresh immediately after execution. Detail views consume the + durable event stream; a 1s full-detail poll is used while that stream is + unavailable. Live tails refresh while inspecting an active attempt. - TUI talks only to the daemon API (role `operator`); the daemon enforces authorization. diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 02aea2f..8fa4470 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -1,7 +1,7 @@ // Package adapter defines the generic executor contract that corral's -// scheduler uses to run nodes. OpenCode is the first implementation -// (Task 3); Codex, Claude and generic CLI drivers must satisfy the same -// interface. Nothing in this package depends on OpenCode. +// scheduler uses to run nodes. OpenCode is the production-wired +// implementation; the standalone Claude Code adapter satisfies the same +// contract. Nothing in this package depends on either provider. package adapter import ( From 0700acdebac5ebbbe2e782ef1c9d8073a9adca9d Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Wed, 12 Aug 2026 00:43:52 -0300 Subject: [PATCH 22/26] fix: pause attempt budgets for permissions --- internal/sched/hardening_test.go | 58 ++++++++++++++++++++++++++++++++ internal/sched/sched.go | 15 +++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/internal/sched/hardening_test.go b/internal/sched/hardening_test.go index 16354c1..f75f14a 100644 --- a/internal/sched/hardening_test.go +++ b/internal/sched/hardening_test.go @@ -3,6 +3,7 @@ package sched_test import ( "context" "testing" + "time" "corral/internal/adapter" "corral/internal/graph" @@ -10,6 +11,63 @@ import ( "corral/internal/verify" ) +func TestPermissionWaitPausesAttemptTimeBudget(t *testing.T) { + st := newStore(t) + clk := fakeClock() + n := agent("w1") + n.Budget.MaxDuration = 10 * tick + + drv := sched.NewFakeDriver(clk, map[string][]sched.Script{ + "w1": {{Delay: time.Hour, Permission: "perm-1"}}, + }) + s := newSched(t, st, drv, &sched.EngineVerifier{Eng: verify.New(t.TempDir())}, clk, sched.Options{Concurrency: 1}) + h, err := s.Create(context.Background(), "run-budget-pause", &graph.Graph{Nodes: []*graph.Node{n}}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + for i := 0; i < 10; i++ { + step(h, clk, ctx) + if state, _ := h.State("w1"); state == graph.StateBlocked { + break + } + } + if state, _ := h.State("w1"); state != graph.StateBlocked { + t.Fatalf("w1 = %s, want blocked on permission", state) + } + + // Time spent waiting for an operator must not consume attempt runtime. + clk.Advance(100 * tick) + ps, err := h.PermissionSession(ctx, "w1") + if err != nil { + t.Fatal(err) + } + if err := ps.RespondPermission(ctx, "perm-1", true); err != nil { + t.Fatal(err) + } + if err := h.Resume(ctx); err != nil { + t.Fatal(err) + } + step(h, clk, ctx) // resumes and re-arms the saved runtime budget + + clk.Advance(8 * tick) + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if state, _ := h.State("w1"); state != graph.StateRunning { + t.Fatalf("w1 = %s before saved budget elapsed, want running", state) + } + + clk.Advance(2 * tick) + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if state, _ := h.State("w1"); state != graph.StateFailed { + t.Fatalf("w1 = %s after saved budget elapsed, want failed", state) + } +} + // TestPermissionRequestBlocksExplicitly drives the permission flow: the // node moves to an explicit blocked state while the session waits, then // resumes automatically after the operator answers, and completes. diff --git a/internal/sched/sched.go b/internal/sched/sched.go index fb139af..64f7497 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -105,6 +105,8 @@ type sessionRec struct { sess adapter.Session deadline time.Time budgeted bool // time budget active + budgetPaused bool + budgetRemain time.Duration abortIsBudget bool // abort was initiated by the scheduler (budget), not operator worktree string branch string @@ -331,6 +333,10 @@ func (h *RunHandle) Step(ctx context.Context) error { } delete(h.sessions, id) h.suspended[id] = rec + if rec.budgeted && !rec.deadline.IsZero() { + rec.budgetRemain = max(rec.deadline.Sub(now), 0) + rec.budgetPaused = true + } rec.budgeted = false payload, _ := json.Marshal(map[string]any{"reason": "permission", "permissionID": pid}) if err := h.transit(ctx, id, graph.StateRunning, graph.StateBlocked, string(payload)); err != nil { @@ -350,10 +356,13 @@ func (h *RunHandle) Step(ctx context.Context) error { } delete(h.suspended, id) h.sessions[id] = rec - if n := h.nodeByID(id); n != nil && n.Budget.MaxDuration > 0 { - // Re-arm the time budget with the remaining time. - rec.deadline = now.Add(time.Until(rec.deadline).Round(0)) + if rec.budgetPaused { + // Permission waits do not consume provider runtime. Re-arm against + // the scheduler clock with the budget left when the node blocked. + rec.deadline = now.Add(rec.budgetRemain) rec.budgeted = true + rec.budgetPaused = false + rec.budgetRemain = 0 } if h.done { h.done = false From 78c9aeaac902802c711b2663e79ac497f97ca4e7 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Tue, 11 Aug 2026 23:36:40 -0300 Subject: [PATCH 23/26] feat: add Claude Code adapter Map headless Claude sessions onto the generic adapter contract with exactly-once completion, scoped permissions, abort handling, and protocol-level regression coverage. --- internal/claudeadapter/adapter.go | 821 ++++++++++++++++++++ internal/claudeadapter/adapter_test.go | 986 +++++++++++++++++++++++++ internal/claudeadapter/permission.go | 477 ++++++++++++ 3 files changed, 2284 insertions(+) create mode 100644 internal/claudeadapter/adapter.go create mode 100644 internal/claudeadapter/adapter_test.go create mode 100644 internal/claudeadapter/permission.go diff --git a/internal/claudeadapter/adapter.go b/internal/claudeadapter/adapter.go new file mode 100644 index 0000000..9fb52a2 --- /dev/null +++ b/internal/claudeadapter/adapter.go @@ -0,0 +1,821 @@ +// Package claudeadapter maps the generic adapter contract onto Claude Code +// CLI/SDK sessions. Each attempt spawns a headless `claude -p` process +// (stream-json output) in the attempt's working directory and streams its +// transcript in; a watcher emits exactly one Completion per attempt, using +// the process exit as the reconciliation fallback when the terminal result +// event is missed. Permission prompts are forwarded through a local unix +// socket broker to the driver's --permission-prompt-tool MCP helper. +// +// The package is self-contained: it does not know about the scheduler and +// is not wired into cmd/corral. +package claudeadapter + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "corral/internal/adapter" +) + +// Options configures the Claude driver. +type Options struct { + // PollInterval is the fallback status-poll period. Completions are + // primarily driven by the process event stream; the poll re-checks + // terminal state in case an event was dropped. + PollInterval time.Duration + // Model overrides the default model for sessions ("" = driver default). + Model string + // Command is the claude binary to spawn ("" = "claude" on PATH). + Command string + // PermissionSocket overrides the unix socket path used by the + // permission broker ("" = auto in the temp dir). + PermissionSocket string + // DisablePermissions skips wiring the --permission-prompt-tool MCP + // helper and its broker socket. Permission prompts are then still + // tracked from the event stream but cannot be answered. + DisablePermissions bool +} + +func (o Options) poll() time.Duration { + if o.PollInterval <= 0 { + return time.Second + } + return o.PollInterval +} + +func (o Options) command() string { + if o.Command != "" { + return o.Command + } + return "claude" +} + +// Driver implements adapter.Driver and adapter.Stepper for Claude Code. +type Driver struct { + opts Options + + mu sync.Mutex + attempts map[string]*attempt // attemptID -> rec + bySession map[string]*attempt // sessionID -> rec + completions chan adapter.Completion + spawn spawnFunc + + brokerOnce sync.Once + broker *permissionBroker + brokerErr error + closed bool +} + +// attempt tracks one live claude session. +type attempt struct { + d *Driver + proc process + attemptID string + nodeID string + sessionID string // current id reported by Claude + launchID string // immutable --session-id used by the helper environment + cwd string + + aborted atomic.Bool + completed atomic.Bool + stopped bool // session closed by the driver; suppress completion + terminal bool // a terminal event (result/error/exit) was seen + subtype string + errMsg string + exited bool + exitErr error + events chan streamEvent + exitedCh chan error // scanner -> watcher: the process Wait result + cancel context.CancelFunc + + mu sync.Mutex + transcript []adapter.Message + permission string // pending permission request id ("" = none); at most one request is admitted +} + +func New(opts Options) *Driver { + return &Driver{ + opts: opts, + attempts: map[string]*attempt{}, + bySession: map[string]*attempt{}, + completions: make(chan adapter.Completion, 64), + spawn: spawnCLI, + } +} + +// Close tears the driver down, killing any live sessions and the +// permission broker. +func (d *Driver) Close() { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return + } + d.closed = true + ats := make([]*attempt, 0, len(d.attempts)) + for _, at := range d.attempts { + ats = append(ats, at) + } + d.mu.Unlock() + for _, at := range ats { + at.cancel() + } + if d.broker != nil { + d.broker.close() + } +} + +// Start creates a headless claude session for the attempt, sends the +// objective, and starts a watcher that completes the attempt from the +// event stream with process-exit as fallback. +func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, error) { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil, fmt.Errorf("start claude: driver is closed") + } + if _, exists := d.attempts[a.ID]; exists { + d.mu.Unlock() + return nil, fmt.Errorf("start claude: attempt %q already started", a.ID) + } + if !d.opts.DisablePermissions { + if err := d.startBroker(); err != nil { + d.mu.Unlock() + return nil, fmt.Errorf("start claude permission broker: %w", err) + } + } + spec := d.specFor(a) + proc, err := d.spawn(ctx, spec) + if err != nil { + d.mu.Unlock() + return nil, fmt.Errorf("start claude: %w", err) + } + atCtx, cancel := context.WithCancel(context.Background()) + at := &attempt{ + d: d, + proc: proc, + attemptID: a.ID, + nodeID: a.NodeID, + sessionID: spec.sessionID, + launchID: spec.sessionID, + cwd: a.Cwd, + events: make(chan streamEvent, 256), + exitedCh: make(chan error, 1), + cancel: cancel, + } + d.attempts[a.ID] = at + d.bySession[spec.sessionID] = at + d.mu.Unlock() + + go d.scan(at) + go at.watch(atCtx) + return &session{at: at}, nil +} + +// specFor builds the claude invocation for an attempt. +func (d *Driver) specFor(a adapter.Attempt) spawnSpec { + sid := newUUID() + args := []string{ + "-p", promptFor(a), + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + "--session-id", sid, + } + if model := d.modelFor(a); model != "" { + args = append(args, "--model", model) + } + if tools := allowedTools(a); len(tools) > 0 { + if !d.opts.DisablePermissions && d.broker != nil { + tools = append(tools, permissionToolName) + } + args = append(args, "--allowedTools") + args = append(args, tools...) + } + env := os.Environ() + if !d.opts.DisablePermissions && d.broker != nil { + cfg, _ := json.Marshal(mcpConfig{ + Servers: map[string]mcpServerConfig{ + permissionServerName: { + Command: helperExecutable(), + Args: []string{helperFlag}, + Env: map[string]string{ + "CORRAL_CLAUDE_BROKER": d.broker.path, + "CORRAL_CLAUDE_ATTEMPT_ID": a.ID, + "CORRAL_CLAUDE_SESSION_ID": sid, + }, + }, + }, + }) + args = append(args, "--mcp-config", string(cfg)) + args = append(args, "--permission-prompt-tool", permissionToolName) + env = append(env, + "CORRAL_CLAUDE_BROKER="+d.broker.path, + "CORRAL_CLAUDE_ATTEMPT_ID="+a.ID, + "CORRAL_CLAUDE_SESSION_ID="+sid, + ) + } + return spawnSpec{ + command: d.opts.command(), + args: args, + env: env, + dir: a.Cwd, + sessionID: sid, + } +} + +func (d *Driver) modelFor(a adapter.Attempt) string { + if a.Model != "" { + return a.Model + } + return d.opts.Model +} + +// Step drains completed attempts (non-blocking). +func (d *Driver) Step(_ context.Context, _ time.Time) []adapter.Completion { + var out []adapter.Completion + for { + select { + case c := <-d.completions: + out = append(out, c) + default: + return out + } + } +} + +// attemptBySession returns the attempt owning a session id, if any. +func (d *Driver) attemptBySession(sessionID string) *attempt { + d.mu.Lock() + defer d.mu.Unlock() + return d.bySession[sessionID] +} + +func (d *Driver) attemptByID(attemptID string) *attempt { + d.mu.Lock() + defer d.mu.Unlock() + return d.attempts[attemptID] +} + +// updateSessionID atomically remaps an attempt when Claude's init event +// reports a session id different from the requested --session-id. +func (d *Driver) updateSessionID(at *attempt, sessionID string) { + if sessionID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + at.mu.Lock() + oldID := at.sessionID + at.sessionID = sessionID + at.mu.Unlock() + if oldID != sessionID { + delete(d.bySession, oldID) + } + d.bySession[sessionID] = at +} + +func (at *attempt) cleanup() { + at.d.mu.Lock() + delete(at.d.attempts, at.attemptID) + at.mu.Lock() + sessionID := at.sessionID + at.mu.Unlock() + delete(at.d.bySession, sessionID) + at.d.mu.Unlock() +} + +// scan reads the claude stream-json output until EOF, dispatches each +// event to the attempt, then reports the process exit to the watcher. The +// exit is delivered through the watcher (not handled here) so the terminal +// decision always sees the full transcript in order. +func (d *Driver) scan(at *attempt) { + sc := bufio.NewScanner(at.proc.stdout()) + for sc.Scan() { + var ev streamEvent + if json.Unmarshal(sc.Bytes(), &ev) != nil { + continue // startup noise / unknown line + } + select { + case at.events <- ev: + default: // dropped; the process exit covers it + } + } + select { + case at.exitedCh <- at.proc.wait(): + default: // watcher already gone (driver closed) + } +} + +// watch drives an attempt to a terminal state. The event stream is the +// primary signal; a poll ticker is the reconciliation fallback. +func (at *attempt) watch(ctx context.Context) { + poll := time.NewTicker(at.d.opts.poll()) + defer poll.Stop() + for { + select { + case <-ctx.Done(): + at.terminate() + at.cleanup() + return + case <-poll.C: + at.d.maybeComplete(context.Background(), at) + case ev := <-at.events: + at.handleEvent(ev) + case err := <-at.exitedCh: + at.drainEvents() + at.onExit(err) + return + } + } +} + +// drainEvents processes every event queued ahead of the exit signal so the +// terminal decision reflects the full transcript. +func (at *attempt) drainEvents() { + for { + select { + case ev := <-at.events: + at.handleEvent(ev) + default: + return + } + } +} + +func (at *attempt) handleEvent(ev streamEvent) { + switch ev.Type { + case "system": + if ev.Subtype != "init" { + return + } + if ev.SessionID != "" { + at.d.updateSessionID(at, ev.SessionID) + } + case "assistant": + at.appendMessage(ev.Message, "assistant") + case "user": + if rid := permissionRequest(ev.Message); rid != "" { + at.mu.Lock() + if at.permission == "" { + at.permission = rid + } + at.mu.Unlock() + return + } + at.appendMessage(ev.Message, "user") + case "result": + at.mu.Lock() + at.terminal = true + at.subtype = ev.Subtype + at.mu.Unlock() + at.d.maybeComplete(context.Background(), at) + case "stream_error": + at.mu.Lock() + at.terminal = true + if ev.Error != "" { + at.errMsg = ev.Error + } + at.mu.Unlock() + at.d.maybeComplete(context.Background(), at) + } +} + +// onExit marks the process exit as the terminal event. It is the +// reconciliation fallback when the result event was dropped. +func (at *attempt) onExit(err error) { + at.mu.Lock() + if at.stopped { + at.mu.Unlock() + return + } + at.terminal = true + at.exited = true + at.exitErr = err + at.mu.Unlock() + at.d.maybeComplete(context.Background(), at) +} + +// terminate hard-kills a live process on driver shutdown. +func (at *attempt) terminate() { + at.mu.Lock() + at.stopped = true + at.mu.Unlock() + select { + case <-at.proc.done(): + return + default: + } + _ = at.proc.signal(syscall.SIGKILL) + select { + case <-at.proc.done(): + case <-time.After(2 * time.Second): + } +} + +// appendMessage accumulates one transcript entry from a stream message. +func (at *attempt) appendMessage(raw json.RawMessage, role string) { + var m sdkMessage + if err := json.Unmarshal(raw, &m); err != nil { + return + } + am := adapter.Message{Role: role, Finish: m.Stop, Cost: m.Cost} + if m.Usage != nil { + am.Tokens = m.Usage.InputTokens + m.Usage.OutputTokens + } + for _, c := range m.Content { + var b contentBlock + if json.Unmarshal(c, &b) != nil { + continue + } + switch b.Type { + case "text": + am.Text += b.Text + case "tool_result": + am.Text += toolResultText(b.Content) + } + } + at.mu.Lock() + at.transcript = append(at.transcript, am) + at.mu.Unlock() +} + +func (at *attempt) snapshot() []adapter.Message { + at.mu.Lock() + defer at.mu.Unlock() + out := make([]adapter.Message, len(at.transcript)) + copy(out, at.transcript) + return out +} + +func (at *attempt) currentSessionID() string { + at.mu.Lock() + defer at.mu.Unlock() + return at.sessionID +} + +// terminalStatus decides whether the attempt reached a terminal state and +// what adapter.Status it maps to. Aborted attempts are terminal +// immediately; otherwise a result/error/exit event must have been seen. +func (at *attempt) terminalStatus() (adapter.Status, bool) { + if at.aborted.Load() { + return adapter.StatusAborted, true + } + at.mu.Lock() + defer at.mu.Unlock() + if !at.terminal { + return "", false + } + if at.subtype == "success" { + return adapter.StatusIdle, true + } + if at.errMsg != "" || at.subtype != "" { + return adapter.StatusError, true + } + if at.exited { + if at.exitErr == nil && len(at.transcript) > 0 { + return adapter.StatusIdle, true + } + return adapter.StatusError, true + } + return adapter.StatusIdle, true +} + +// maybeComplete emits a completion exactly once per attempt, guarded +// against duplicate or missing events. +func (d *Driver) maybeComplete(ctx context.Context, at *attempt) { + status, ok := at.terminalStatus() + if !ok { + return + } + if !at.completed.CompareAndSwap(false, true) { + return // duplicate event; already handled + } + c := adapter.Completion{ + AttemptID: at.attemptID, + SessionID: at.currentSessionID(), + Status: status, + Messages: at.snapshot(), + } + select { + case d.completions <- c: + default: + log.Printf("claudeadapter: completion channel full; dropping %s", at.attemptID) + } +} + +// session implements adapter.Session and adapter.PermissionSession for a +// live claude session. +type session struct { + at *attempt +} + +func (s *session) ID() string { + s.at.mu.Lock() + defer s.at.mu.Unlock() + return s.at.sessionID +} +func (s *session) ServerID() string { return s.at.d.opts.command() } + +// Send is unsupported: each claude attempt is one headless process running +// its prompt to completion, so there is no live process to steer. +func (s *session) Send(_ context.Context, text string) error { + return fmt.Errorf("claudeadapter: cannot send %q to session %s: sessions run one prompt to completion", text, s.ID()) +} + +func (s *session) Abort(ctx context.Context) error { + s.at.aborted.Store(true) + select { + case <-s.at.proc.done(): + return nil // already exited + default: + } + // SIGTERM is Claude Code's graceful stop (it aborts the turn, runs + // SessionEnd hooks and exits 143); SIGKILL is the hard fallback. + if err := s.at.proc.signal(syscall.SIGTERM); err != nil { + return err + } + select { + case <-s.at.proc.done(): + return nil + case <-ctx.Done(): + _ = s.at.proc.signal(syscall.SIGKILL) + return ctx.Err() + case <-time.After(5 * time.Second): + return s.at.proc.signal(syscall.SIGKILL) + } +} + +func (s *session) Status(_ context.Context) (adapter.Status, error) { + if s.at.aborted.Load() { + return adapter.StatusAborted, nil + } + if st, ok := s.at.terminalStatus(); ok { + return st, nil + } + return adapter.StatusRunning, nil +} + +func (s *session) Messages(_ context.Context) ([]adapter.Message, error) { + return s.at.snapshot(), nil +} + +func (s *session) Close(context.Context) error { + s.at.cancel() + return nil +} + +func (s *session) PendingPermission(_ context.Context) (string, bool, error) { + s.at.mu.Lock() + defer s.at.mu.Unlock() + if s.at.permission != "" { + return s.at.permission, true, nil + } + return "", false, nil +} + +func (s *session) RespondPermission(ctx context.Context, id string, allow bool) error { + s.at.mu.Lock() + if s.at.permission == "" { + s.at.mu.Unlock() + return fmt.Errorf("claudeadapter: no pending permission request") + } + if s.at.permission != id { + pending := s.at.permission + s.at.mu.Unlock() + return fmt.Errorf("claudeadapter: permission %q is not pending (want %q)", id, pending) + } + s.at.mu.Unlock() + if err := s.at.d.respondPermission(ctx, s.at, id, allow); err != nil { + return err + } + s.at.mu.Lock() + if s.at.permission == id { + s.at.permission = "" // resolved; the scheduler resumes automatically + } + s.at.mu.Unlock() + return nil +} + +// respondPermission delivers a decision through the broker. When no helper +// is waiting for the id (e.g. a stream-only tracked prompt) the request is +// treated as resolved. +func (d *Driver) respondPermission(ctx context.Context, at *attempt, id string, allow bool) error { + if d.broker == nil { + return nil + } + return d.broker.respond(ctx, at, id, allow) +} + +// promptFor builds the headless prompt for an attempt, mirroring ocx. +func promptFor(a adapter.Attempt) string { + var b strings.Builder + if a.Role != "" { + b.WriteString("(role: " + a.Role + ")\n") + } + b.WriteString(a.Objective) + if a.Feedback != "" { + b.WriteString("\n\nPrevious attempt was rejected. Fix these issues:\n" + a.Feedback) + } + return b.String() +} + +// allowedTools maps an attempt's write scope onto Claude Code permission +// rules: read-only tools are always approved and scoped Edit rules cover +// each writable path, so in-scope work runs without prompts. +func allowedTools(a adapter.Attempt) []string { + tools := []string{"Read", "Glob", "Grep"} + for _, p := range a.WriteScope { + tools = append(tools, "Edit("+p+")", "Write("+p+")") + } + return tools +} + +// newUUID returns a v4 UUID used as the claude --session-id. +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Non-crypto fallback (still RFC4122-shaped). + for i := range b { + b[i] = byte(i * 7) + } + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// --------------------------------------------------------------------------- +// Claude Code stream-json protocol types. + +type streamEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + Message json.RawMessage `json:"message"` + Result string `json:"result"` + Error string `json:"error"` + Event json.RawMessage `json:"event"` +} + +type sdkMessage struct { + Role string `json:"role"` + Content []json.RawMessage `json:"content"` + Usage *sdkUsage `json:"usage"` + Cost float64 `json:"cost"` + Stop string `json:"stop_reason"` +} + +type sdkUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + State string `json:"state"` + ToolName string `json:"tool_name"` + RequestID string `json:"request_id"` + ID string `json:"id"` +} + +// toolResultText flattens a tool_result content field, which may be a plain +// string or an array of text blocks. +func toolResultText(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "" + } + var b strings.Builder + for _, bl := range blocks { + if bl.Type == "text" { + b.WriteString(bl.Text) + } + } + return b.String() +} + +// permissionRequest returns a pending permission request id from a user +// message, or "" when the message carries none. +func permissionRequest(raw json.RawMessage) string { + var m sdkMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + for _, c := range m.Content { + var b contentBlock + if json.Unmarshal(c, &b) != nil || b.Type != "permission_request" { + continue + } + if b.State != "needs_response" && b.State != "" { + continue + } + if b.RequestID != "" { + return b.RequestID + } + if b.ID != "" { + return b.ID + } + } + return "" +} + +// --------------------------------------------------------------------------- +// Process abstraction. The default implementation spawns the claude binary; +// tests substitute an in-process fake speaking the same stream-json protocol. + +type process interface { + stdout() io.Reader + done() <-chan struct{} // closed once the process has exited + wait() error // Wait result; valid once done() is closed + signal(os.Signal) error +} + +type spawnSpec struct { + command string + args []string + env []string + dir string + sessionID string +} + +type spawnFunc func(ctx context.Context, spec spawnSpec) (process, error) + +type cliProcess struct { + cmd *exec.Cmd + out io.ReadCloser + doneCh chan struct{} + waitMu sync.Mutex + waitErr error +} + +func (p *cliProcess) stdout() io.Reader { return p.out } +func (p *cliProcess) done() <-chan struct{} { + return p.doneCh +} +func (p *cliProcess) wait() error { + <-p.doneCh + p.waitMu.Lock() + defer p.waitMu.Unlock() + return p.waitErr +} +func (p *cliProcess) signal(sig os.Signal) error { + return p.cmd.Process.Signal(sig) +} + +func spawnCLI(ctx context.Context, spec spawnSpec) (process, error) { + cmd := exec.CommandContext(ctx, spec.command, spec.args...) + cmd.Dir = spec.dir + cmd.Env = spec.env + in, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + out, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + errw, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + // The prompt comes from argv; never stream to stdin, and drain stderr + // so a chatty CLI cannot deadlock the process. + _ = in.Close() + go io.Copy(io.Discard, errw) + p := &cliProcess{cmd: cmd, out: out, doneCh: make(chan struct{})} + go func() { + err := cmd.Wait() + p.waitMu.Lock() + p.waitErr = err + p.waitMu.Unlock() + close(p.doneCh) + }() + return p, nil +} diff --git a/internal/claudeadapter/adapter_test.go b/internal/claudeadapter/adapter_test.go new file mode 100644 index 0000000..a247621 --- /dev/null +++ b/internal/claudeadapter/adapter_test.go @@ -0,0 +1,986 @@ +package claudeadapter + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "corral/internal/adapter" + "corral/internal/clock" + "corral/internal/graph" + "corral/internal/livetest" + "corral/internal/sched" + "corral/internal/store" + "corral/internal/verify" +) + +// TestMain forwards the --corral-claude-permission-tool flag to the package's +// MCP permission helper so the live test's real claude can call back through +// the driver's broker (the same contract an embedding binary must honour). +func TestMain(m *testing.M) { + if len(os.Args) > 1 && os.Args[1] == helperFlag { + RunPermissionHelper() + os.Exit(0) + } + os.Exit(m.Run()) +} + +// fakeProc simulates a claude process: it emits stream-json events written by +// the test and records signals for abort assertions. +type fakeProc struct { + r *io.PipeReader + w *io.PipeWriter + doneCh chan struct{} + sigCh chan os.Signal + spec spawnSpec + mu sync.Mutex + exited bool + waitErr error +} + +func newFakeProc() (*fakeProc, *io.PipeReader) { + r, w := io.Pipe() + return &fakeProc{ + r: r, + w: w, + doneCh: make(chan struct{}), + sigCh: make(chan os.Signal, 8), + }, r +} + +func (f *fakeProc) stdout() io.Reader { return f.r } +func (f *fakeProc) done() <-chan struct{} { + return f.doneCh +} +func (f *fakeProc) wait() error { + f.mu.Lock() + defer f.mu.Unlock() + return f.waitErr +} +func (f *fakeProc) signal(sig os.Signal) error { + f.sigCh <- sig + return nil +} + +// finish ends the fake process with a Wait result, closing the stream. +func (f *fakeProc) finish(err error) { + f.mu.Lock() + if !f.exited { + f.exited = true + f.waitErr = err + f.w.Close() + close(f.doneCh) + } + f.mu.Unlock() +} + +// write emits one stream-json event line. +func (f *fakeProc) write(ev map[string]any) { + b, err := json.Marshal(ev) + if err != nil { + panic(err) + } + f.w.Write(append(b, '\n')) +} + +// fakeSpawn returns a spawn func bound to a fake process, recording the +// spawn spec for assertions. +func fakeSpawn(fp *fakeProc) spawnFunc { + return func(_ context.Context, spec spawnSpec) (process, error) { + fp.spec = spec + return fp, nil + } +} + +func attemptFor(id, objective string) adapter.Attempt { + return adapter.Attempt{ + ID: id, + NodeID: id, + Objective: objective, + Role: "worker", + Model: "claude-sonnet-5", + Cwd: "/tmp/work", + WriteScope: []string{"src"}, + MaxDurationSeconds: 600, + } +} + +// drainSteps pulls one completion from the driver via Step. +func drainSteps(d *Driver, want int) []adapter.Completion { + deadline := time.Now().Add(10 * time.Second) + var out []adapter.Completion + for len(out) < want && time.Now().Before(deadline) { + cs := d.Step(context.Background(), time.Now()) + out = append(out, cs...) + if len(cs) == 0 { + time.Sleep(10 * time.Millisecond) + } + } + return out +} + +// drainFor polls Step for dur, returning as soon as any completion arrives. +// It is used to assert that no duplicate completion is ever emitted. +func drainFor(d *Driver, dur time.Duration) []adapter.Completion { + deadline := time.Now().Add(dur) + var out []adapter.Completion + for time.Now().Before(deadline) { + out = append(out, d.Step(context.Background(), time.Now())...) + if len(out) > 0 { + return out + } + time.Sleep(10 * time.Millisecond) + } + return out +} + +// waitPending polls PendingPermission until the request with the wanted id +// is pending (the broker and the event watcher populate it asynchronously). +func waitPending(t *testing.T, ps adapter.PermissionSession, want string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + pid, ok, err := ps.PendingPermission(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok && pid == want { + return + } + time.Sleep(10 * time.Millisecond) + } + pid, ok, _ := ps.PendingPermission(context.Background()) + t.Fatalf("pending permission = %q, %v; want %q, true", pid, ok, want) +} + +func TestDriverImplementsAdapterInterfaces(t *testing.T) { + var _ adapter.Driver = (*Driver)(nil) + var _ adapter.Stepper = (*Driver)(nil) + var _ adapter.Session = (*session)(nil) + var _ adapter.PermissionSession = (*session)(nil) +} + +func TestSystemInitUsesClaudeStreamShapeAndRemapsSession(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + oldID := sess.ID() + actualID := newUUID() + sess.(*session).at.handleEvent(streamEvent{ + Type: "system", + Subtype: "init", + SessionID: actualID, + }) + if got := sess.ID(); got != actualID { + t.Fatalf("session id = %q, want init event id %q", got, actualID) + } + if got := drv.attemptBySession(actualID); got != sess.(*session).at { + t.Fatal("actual session id was not mapped to its attempt") + } + if got := drv.attemptBySession(oldID); got != nil { + t.Fatal("generated session id remained mapped after init remap") + } + fp.finish(nil) +} + +// TestStartCompletion drives the happy path: system subtype init, an assistant turn, +// tool callbacks, and a terminal result event. The completion must be emitted +// exactly once with the accumulated transcript, and the spawned claude must +// have received the expected headless flags. +func TestStartCompletion(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 50 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + a := attemptFor("w1/1", "create src/alpha.txt") + sess, err := drv.Start(context.Background(), a) + if err != nil { + t.Fatal(err) + } + + // The session id is the UUID the driver generated and passed to claude. + sid := sess.ID() + if len(sid) != 36 || strings.Count(sid, "-") != 4 { + t.Fatalf("session id %q is not a UUID", sid) + } + + ev := map[string]any{"type": "system", "subtype": "init", "session_id": sid} + fp.write(ev) + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", "stop_reason": "end_turn", + "usage": map[string]any{"input_tokens": 10, "output_tokens": 20}, + "content": []map[string]any{{"type": "text", "text": "I will create the file."}}, + }}) + fp.write(map[string]any{"type": "user", "message": map[string]any{ + "role": "user", + "content": []map[string]any{{ + "type": "tool_result", "tool_use_id": "tu1", + "content": []map[string]any{{"type": "text", "text": "wrote src/alpha.txt"}}, + }}, + }}) + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", "stop_reason": "end_turn", + "usage": map[string]any{"input_tokens": 5, "output_tokens": 3}, + "content": []map[string]any{{"type": "text", "text": "Done. src/alpha.txt exists."}}, + }}) + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sid, "result": "Done."}) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("got %d completions, want 1", len(cs)) + } + c := cs[0] + if c.AttemptID != "w1/1" { + t.Errorf("attempt id = %q, want w1/1", c.AttemptID) + } + if c.SessionID != sid { + t.Errorf("session id = %q, want %q", c.SessionID, sid) + } + if c.Status != adapter.StatusIdle { + t.Errorf("status = %q, want idle", c.Status) + } + msgs := c.Messages + if len(msgs) != 3 { + t.Fatalf("messages = %d, want 3", len(msgs)) + } + if msgs[0].Role != "assistant" || !strings.Contains(msgs[0].Text, "I will create the file.") { + t.Errorf("msg[0] = %+v", msgs[0]) + } + if msgs[0].Tokens != 30 { + t.Errorf("msg[0] tokens = %d, want 30", msgs[0].Tokens) + } + if msgs[1].Role != "user" || !strings.Contains(msgs[1].Text, "wrote src/alpha.txt") { + t.Errorf("msg[1] = %+v", msgs[1]) + } + if !strings.Contains(msgs[2].Text, "Done.") { + t.Errorf("msg[2] = %+v", msgs[2]) + } + + // The session view exposes the same transcript. + got, err := sess.Messages(context.Background()) + if err != nil || len(got) != 3 { + t.Fatalf("session messages: %v, %d", err, len(got)) + } + + // No duplicate completion when the process exits after the result event. + if extra := drainFor(drv, 300*time.Millisecond); len(extra) != 0 { + t.Fatalf("duplicate completions: %+v", extra) + } + + // The claude invocation carries the expected headless arguments. + args := strings.Join(fp.spec.args, " ") + for _, want := range []string{"-p", "--output-format", "stream-json", "--verbose", + "--include-partial-messages", "--session-id", sid, "--model", "claude-sonnet-5", + "--allowedTools"} { + if !strings.Contains(args, want) { + t.Errorf("args %q missing %q", args, want) + } + } + hasEdit := false + hasWrite := false + for _, arg := range fp.spec.args { + if strings.HasPrefix(arg, "Edit(") { + hasEdit = true + } + if strings.HasPrefix(arg, "Write(") { + hasWrite = true + } + } + if !hasEdit { + t.Errorf("write scope not mapped to an Edit rule: %v", fp.spec.args) + } + if !hasWrite { + t.Errorf("write scope not mapped to a Write rule: %v", fp.spec.args) + } + if fp.spec.dir != "/tmp/work" { + t.Errorf("cwd = %q, want /tmp/work", fp.spec.dir) + } + if !strings.Contains(fp.spec.args[1], "(role: worker)") { + t.Errorf("prompt missing role header: %q", fp.spec.args[1]) + } + if !strings.Contains(fp.spec.args[1], a.Objective) { + t.Errorf("prompt missing objective: %q", fp.spec.args[1]) + } +} + +// TestExitFallbackCompletion verifies the reconciliation path: when the +// terminal result event is missed the process exit decides the completion, +// mapping exit 0 to idle and a non-zero exit to error. +func TestExitFallbackCompletion(t *testing.T) { + for _, tc := range []struct { + name string + err error + want adapter.Status + }{ + {"exit zero", nil, adapter.StatusIdle}, + {"exit nonzero", fmt.Errorf("exit status 1"), adapter.StatusError}, + } { + t.Run(tc.name, func(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "write a file")) + if err != nil { + t.Fatal(err) + } + sid := sess.ID() + fp.write(map[string]any{"type": "system", "subtype": "init", "session_id": sid}) + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "working..."}}, + }}) + // No result event: the completion must still arrive via exit. + fp.finish(tc.err) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("got %d completions, want 1", len(cs)) + } + if cs[0].Status != tc.want { + t.Errorf("status = %q, want %q", cs[0].Status, tc.want) + } + if len(cs[0].Messages) != 1 { + t.Errorf("messages = %d, want 1", len(cs[0].Messages)) + } + if extra := drainFor(drv, 300*time.Millisecond); len(extra) != 0 { + t.Fatalf("duplicate completions: %+v", extra) + } + }) + } +} + +// TestAbort verifies that aborting a running attempt yields exactly one +// completion with StatusAborted, and that the claude process receives a +// terminate signal. +func TestAbort(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "long task")) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "system", "subtype": "init", "session_id": sess.ID()}) + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "starting..."}}, + }}) + + // Give the watcher time to record the events, then abort mid-run. + deadline := time.Now().Add(5 * time.Second) + for { + st, _ := sess.Status(context.Background()) + if st == adapter.StatusRunning { + break + } + if time.Now().After(deadline) { + t.Fatal("attempt never reached running") + } + time.Sleep(10 * time.Millisecond) + } + if err := sess.Abort(context.Background()); err != nil { + t.Fatal(err) + } + // The fake claude observes the SIGTERM and exits. + select { + case sig := <-fp.sigCh: + if sig != syscall.SIGTERM { + t.Errorf("signal = %v, want SIGTERM", sig) + } + case <-time.After(5 * time.Second): + t.Fatal("fake process never received the abort signal") + } + fp.finish(fmt.Errorf("signal: terminated")) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("got %d completions, want 1", len(cs)) + } + if cs[0].Status != adapter.StatusAborted { + t.Errorf("status = %q, want aborted", cs[0].Status) + } + if got, _ := sess.Status(context.Background()); got != adapter.StatusAborted { + t.Errorf("session status = %q, want aborted", got) + } +} + +// TestPermissionBroker exercises the real permission transport end to end: a +// helper goroutine acting as claude's MCP permission tool connects to the +// broker socket, parks a request, and waits; the scheduler-side session sees +// the pending permission and RespondPermission delivers the decision. +func TestPermissionBroker(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: false, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "write src/alpha.txt")) + if err != nil { + t.Fatal(err) + } + ps, ok := sess.(adapter.PermissionSession) + if !ok { + t.Fatal("session does not implement PermissionSession") + } + sid := sess.ID() + if drv.broker == nil { + t.Fatal("permission broker not started") + } + // The spawned claude got the permission tool wiring. + args := strings.Join(fp.spec.args, " ") + for _, want := range []string{"--mcp-config", "--permission-prompt-tool", "mcp__corral__handle_permission_prompt"} { + if !strings.Contains(args, want) { + t.Errorf("args missing %q: %s", want, args) + } + } + if strings.Count(args, permissionToolName) < 2 { + t.Errorf("permission MCP tool not explicitly allowed and configured: %s", args) + } + configIndex := -1 + for i, arg := range fp.spec.args { + if arg == "--mcp-config" { + configIndex = i + 1 + break + } + } + if configIndex < 0 || configIndex >= len(fp.spec.args) { + t.Fatal("missing MCP config argument") + } + var config mcpConfig + if err := json.Unmarshal([]byte(fp.spec.args[configIndex]), &config); err != nil { + t.Fatalf("decode MCP config: %v", err) + } + helperEnv := config.Servers[permissionServerName].Env + if helperEnv["CORRAL_CLAUDE_ATTEMPT_ID"] != "w1/1" || helperEnv["CORRAL_CLAUDE_SESSION_ID"] != sid { + t.Fatalf("helper identity env = %v", helperEnv) + } + + fp.write(map[string]any{"type": "system", "subtype": "init", "session_id": sid}) + + // A helper (what claude spawns as --corral-claude-permission-tool) + // connects to the broker and parks a permission request. + helperDone := make(chan decision, 1) + helperErr := make(chan error, 1) + go func() { + conn, err := net.Dial("unix", drv.broker.path) + if err != nil { + helperErr <- err + return + } + defer conn.Close() + req := map[string]any{ + "attempt_id": "w1/1", "session_id": sid, "request_id": "req-1", "tool_name": "Write", + "prompt": "write file", "tool_input": map[string]any{"file_path": "src/alpha.txt"}, + } + if err := json.NewEncoder(conn).Encode(req); err != nil { + helperErr <- err + return + } + var reply decision + if err := json.NewDecoder(conn).Decode(&reply); err != nil { + helperErr <- err + return + } + helperDone <- reply + }() + + // The scheduler's view: the permission is pending and the session is + // permission-capable. + waitPending(t, ps, "req-1") + + // Claude may issue tool calls concurrently. The adapter exposes one + // permission at a time, so a second request is denied immediately instead + // of replacing the first and leaving its helper stranded. + conn, err := net.Dial("unix", drv.broker.path) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(conn).Encode(map[string]any{ + "attempt_id": "w1/1", "session_id": sid, "request_id": "req-concurrent", "tool_name": "Bash", + }); err != nil { + t.Fatal(err) + } + var concurrent decision + if err := json.NewDecoder(conn).Decode(&concurrent); err != nil { + t.Fatal(err) + } + conn.Close() + if concurrent.Allow || !strings.Contains(concurrent.Message, "already pending") { + t.Fatalf("concurrent permission decision = %+v", concurrent) + } + if pid, ok, _ := ps.PendingPermission(context.Background()); !ok || pid != "req-1" { + t.Fatalf("concurrent request replaced pending permission: %q, %v", pid, ok) + } + + // Approve it; the parked helper must receive the allow decision. + if err := ps.RespondPermission(context.Background(), "req-1", true); err != nil { + t.Fatal(err) + } + select { + case reply := <-helperDone: + if !reply.Allow { + t.Errorf("helper got deny, want allow") + } + case err := <-helperErr: + t.Fatalf("helper error: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("helper never received the permission decision") + } + if pid, ok, _ := ps.PendingPermission(context.Background()); ok { + t.Errorf("permission still pending after response: %q", pid) + } + if err := ps.RespondPermission(context.Background(), "req-1", true); err == nil { + t.Fatal("responding twice to permission succeeded") + } + + // A denied request clears the pending state and returns deny. + go func() { + conn, err := net.Dial("unix", drv.broker.path) + if err != nil { + helperErr <- err + return + } + defer conn.Close() + json.NewEncoder(conn).Encode(map[string]any{ + "attempt_id": "w1/1", "session_id": sid, "request_id": "req-2", "tool_name": "Bash", + }) + var reply decision + if err := json.NewDecoder(conn).Decode(&reply); err != nil { + helperErr <- err + return + } + helperDone <- reply + }() + waitPending(t, ps, "req-2") + if err := ps.RespondPermission(context.Background(), "req-2", false); err != nil { + t.Fatal(err) + } + select { + case reply := <-helperDone: + if reply.Allow { + t.Errorf("helper got allow, want deny") + } + case err := <-helperErr: + t.Fatalf("helper error: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("helper never received the denied decision") + } + + // The attempt still completes normally afterwards. + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "done writing"}}, + }}) + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sid}) + fp.finish(nil) + cs := drainSteps(drv, 1) + if len(cs) != 1 || cs[0].Status != adapter.StatusIdle { + t.Fatalf("completion = %+v, want a single idle", cs) + } +} + +// TestPermissionHelperClaude221226InputShape crosses the full MCP helper and +// broker boundary with Claude Code 2.1.226's permission-tool arguments: +// {tool_name,input,tool_use_id}. It spawns the actual exported helper path so +// attempt/session ownership must be obtained from the child environment. +func TestPermissionHelperClaude221226InputShape(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + a := attemptFor("w1/1", "write src/alpha.txt") + sess, err := drv.Start(context.Background(), a) + if err != nil { + t.Fatal(err) + } + ps := sess.(adapter.PermissionSession) + + helper := exec.Command(os.Args[0], helperFlag) + helper.Env = append(os.Environ(), + "CORRAL_CLAUDE_BROKER="+drv.broker.path, + "CORRAL_CLAUDE_ATTEMPT_ID="+a.ID, + "CORRAL_CLAUDE_SESSION_ID="+sess.ID(), + ) + helperIn, err := helper.StdinPipe() + if err != nil { + t.Fatal(err) + } + helperOut, err := helper.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var helperStderr strings.Builder + helper.Stderr = &helperStderr + if err := helper.Start(); err != nil { + t.Fatal(err) + } + waited := false + t.Cleanup(func() { + if waited { + return + } + _ = helperIn.Close() + _ = helper.Process.Kill() + _ = helper.Wait() + }) + enc := json.NewEncoder(helperIn) + dec := json.NewDecoder(helperOut) + + call := func(rpcID, toolUseID string, allow bool) string { + t.Helper() + arguments := map[string]any{ + "tool_name": "Write", + "input": map[string]any{"file_path": "src/alpha.txt", "content": "ok\n"}, + } + if toolUseID != "" { + arguments["tool_use_id"] = toolUseID + } + params, err := json.Marshal(map[string]any{ + "name": "handle_permission_prompt", + "arguments": arguments, + }) + if err != nil { + t.Fatal(err) + } + if err := enc.Encode(rpcRequest{ + ID: json.RawMessage(rpcID), + Method: "tools/call", + Params: params, + }); err != nil { + t.Fatal(err) + } + + var requestID string + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if id, ok, err := ps.PendingPermission(context.Background()); err != nil { + t.Fatal(err) + } else if ok { + requestID = id + break + } + time.Sleep(10 * time.Millisecond) + } + if requestID == "" { + t.Fatal("helper request never became pending") + } + if toolUseID != "" && requestID != toolUseID { + t.Fatalf("permission id = %q, want tool_use_id %q", requestID, toolUseID) + } + if toolUseID == "" && (len(requestID) != 36 || strings.Count(requestID, "-") != 4) { + t.Fatalf("generated permission id = %q, want UUID", requestID) + } + if err := ps.RespondPermission(context.Background(), requestID, allow); err != nil { + t.Fatal(err) + } + + var response struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := dec.Decode(&response); err != nil { + t.Fatal(err) + } + if response.Result.IsError || len(response.Result.Content) != 1 || response.Result.Content[0].Type != "text" { + t.Fatalf("MCP response = %+v", response) + } + return response.Result.Content[0].Text + } + + allowText := call("1", "toolu_01ABC", true) + var allowed struct { + Behavior string `json:"behavior"` + UpdatedInput map[string]any `json:"updatedInput"` + } + if err := json.Unmarshal([]byte(allowText), &allowed); err != nil { + t.Fatalf("decode allow decision %q: %v", allowText, err) + } + if allowed.Behavior != "allow" || allowed.UpdatedInput["file_path"] != "src/alpha.txt" { + t.Fatalf("allow decision = %+v", allowed) + } + + denyText := call("2", "", false) + var denied struct { + Behavior string `json:"behavior"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(denyText), &denied); err != nil { + t.Fatalf("decode deny decision %q: %v", denyText, err) + } + if denied.Behavior != "deny" || denied.Message == "" { + t.Fatalf("deny decision = %+v", denied) + } + + if err := helperIn.Close(); err != nil { + t.Fatal(err) + } + if err := helper.Wait(); err != nil { + t.Fatalf("helper exit: %v; stderr: %s", err, helperStderr.String()) + } + waited = true + fp.finish(nil) +} + +// TestStreamPermissionTracking covers the fallback path where the pending +// permission is observed from the event stream itself (no broker helper), as +// ocxadapter does with its permission.updated events. +func TestStreamPermissionTracking(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + ps, ok := sess.(adapter.PermissionSession) + if !ok { + t.Fatal("session does not implement PermissionSession") + } + fp.write(map[string]any{"type": "system", "subtype": "init", "session_id": sess.ID()}) + fp.write(map[string]any{"type": "user", "message": map[string]any{ + "role": "user", + "content": []map[string]any{{ + "type": "permission_request", "state": "needs_response", + "request_id": "stream-req", "tool_name": "Write", + }}, + }}) + + waitPending(t, ps, "stream-req") + if err := ps.RespondPermission(context.Background(), "wrong-id", false); err == nil { + t.Fatal("responding to wrong permission id succeeded") + } + if pid, ok, _ := ps.PendingPermission(context.Background()); !ok || pid != "stream-req" { + t.Fatalf("wrong response changed pending permission: %q, %v", pid, ok) + } + // With permissions disabled there is no broker; responding still clears + // the pending state. + if err := ps.RespondPermission(context.Background(), "stream-req", false); err != nil { + t.Fatal(err) + } + if pid, ok, _ := ps.PendingPermission(context.Background()); ok { + t.Errorf("permission still pending: %q", pid) + } + if err := ps.RespondPermission(context.Background(), "stream-req", false); err == nil { + t.Fatal("responding to a resolved permission succeeded") + } + fp.finish(nil) +} + +func TestCLIProcessWaitBlocksUntilExitResultIsCached(t *testing.T) { + p := &cliProcess{doneCh: make(chan struct{})} + want := fmt.Errorf("exit status 7") + got := make(chan error, 1) + go func() { got <- p.wait() }() + + select { + case err := <-got: + t.Fatalf("wait returned before process exit: %v", err) + case <-time.After(50 * time.Millisecond): + } + + p.waitMu.Lock() + p.waitErr = want + p.waitMu.Unlock() + close(p.doneCh) + select { + case err := <-got: + if err != want { + t.Fatalf("wait error = %v, want %v", err, want) + } + case <-time.After(time.Second): + t.Fatal("wait did not return after process exit") + } +} + +// TestStartError covers the failure path: when the claude binary cannot be +// spawned the driver returns an error so the scheduler fails the node. +func TestStartError(t *testing.T) { + drv := New(Options{DisablePermissions: true}) + defer drv.Close() + drv.spawn = func(context.Context, spawnSpec) (process, error) { + return nil, fmt.Errorf("claude: exec: not found") + } + if _, err := drv.Start(context.Background(), attemptFor("w1/1", "task")); err == nil { + t.Fatal("expected start error") + } +} + +func TestStartRejectsDuplicateAttemptID(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + a := attemptFor("w1/1", "task") + if _, err := drv.Start(context.Background(), a); err != nil { + t.Fatal(err) + } + if _, err := drv.Start(context.Background(), a); err == nil || !strings.Contains(err.Error(), "already started") { + t.Fatalf("duplicate Start error = %v", err) + } + fp.finish(nil) +} + +func TestStartRejectsClosedDriverWithoutSpawning(t *testing.T) { + drv := New(Options{DisablePermissions: true}) + drv.Close() + spawned := false + drv.spawn = func(context.Context, spawnSpec) (process, error) { + spawned = true + return nil, fmt.Errorf("unexpected spawn") + } + if _, err := drv.Start(context.Background(), attemptFor("w1/1", "task")); err == nil || !strings.Contains(err.Error(), "closed") { + t.Fatalf("Start after Close error = %v", err) + } + if spawned { + t.Fatal("closed driver spawned a process") + } +} + +// TestCloseKillsSessions verifies the driver tears down live sessions. +func TestCloseKillsSessions(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "system", "subtype": "init", "session_id": sess.ID()}) + fp.finish(nil) + + drv.Close() + // A second Close is a no-op. + drv.Close() + + // No completion may be emitted after the driver is closed (the scan + // path is suppressed by terminate). + if cs := drv.Step(context.Background(), time.Now()); len(cs) != 0 { + t.Fatalf("completions after close: %+v", cs) + } +} + +// skipLive gates tests that drive the real claude CLI. +func skipLive(t *testing.T) { + t.Helper() + livetest.SkipIfDisabled(t) + if os.Getenv("CORRAL_CLAUDE_LIVE") != "1" { + t.Skip("live claude test disabled (CORRAL_CLAUDE_LIVE=1 to run)") + } + if _, err := exec.LookPath("claude"); err != nil { + t.Skip("claude binary not found") + } +} + +// TestLiveClaudeSingleNode drives the real claude CLI through the scheduler: +// one worker writes a marker file in its own worktree, passes a command +// verification gate, and the run records exactly one attempt. +func TestLiveClaudeSingleNode(t *testing.T) { + skipLive(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + + proj, err := os.MkdirTemp("", "corral-claude-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(proj) }) + for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"commit", "-q", "--allow-empty", "-m", "init"}} { + cmd := exec.Command("git", args...) + cmd.Dir = proj + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + + st, err := store.Open(filepath.Join(t.TempDir(), "claude.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + drv := New(Options{PollInterval: 500 * time.Millisecond}) + t.Cleanup(func() { drv.Close() }) + + n := &graph.Node{ + ID: "w1", Type: graph.NodeAgent, Role: "worker", + Objective: "Create a file named claude-marker.txt containing exactly one line: CORRAL-CLAUDE-OK. Do not run any other commands.", + AcceptanceCriteria: []string{"marker file produced"}, + WriteScope: []string{"claude-marker.txt"}, + Verification: &graph.Verification{Kind: "command", Command: []string{"grep", "-q", "CORRAL-CLAUDE-OK", "claude-marker.txt"}}, + Priority: graph.PriorityNormal, + RetryPolicy: graph.RetryPolicy{MaxRetries: 1, Backoff: 5 * time.Second}, + Budget: graph.Budget{MaxDuration: 10 * time.Minute}, + } + + s := sched.New(st, drv, &sched.EngineVerifier{Eng: verify.New(proj)}, clock.Real{}, sched.Options{ + Concurrency: 1, + }) + h, err := s.Create(ctx, "run-claude", &graph.Graph{Nodes: []*graph.Node{n}}) + if err != nil { + t.Fatal(err) + } + if err := h.Run(ctx, 500*time.Millisecond); err != nil { + for _, id := range []string{"w1"} { + if st2, _ := h.State(graph.NodeID(id)); st2 != "" { + t.Logf("at timeout: %s -> %s", id, st2) + } + } + t.Fatalf("run: %v", err) + } + + if st1, _ := h.State("w1"); st1 != graph.StateDone { + t.Errorf("w1 state = %s, want done", st1) + } + atts, err := st.Attempts(ctx, "run-claude", "w1") + if err != nil { + t.Fatal(err) + } + if len(atts) != 1 { + t.Fatalf("w1 attempts = %d, want exactly 1", len(atts)) + } + if atts[0].Status != "done" { + t.Errorf("w1 attempt status = %s, want done", atts[0].Status) + } + if len(atts[0].SessionID) != 36 { + t.Errorf("w1 session id = %q, want a UUID (claude --session-id)", atts[0].SessionID) + } + data, err := os.ReadFile(filepath.Join(proj, "claude-marker.txt")) + if err != nil { + t.Fatalf("claude-marker.txt missing: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "CORRAL-CLAUDE-OK" { + t.Errorf("claude-marker.txt = %q, want CORRAL-CLAUDE-OK", got) + } +} diff --git a/internal/claudeadapter/permission.go b/internal/claudeadapter/permission.go new file mode 100644 index 0000000..04b557c --- /dev/null +++ b/internal/claudeadapter/permission.go @@ -0,0 +1,477 @@ +package claudeadapter + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Claude Code headless sessions surface permission prompts by invoking the +// --permission-prompt-tool MCP tool; the driver answers them through a local +// unix socket broker. +// +// claude --permission-prompt-tool mcp__corral__handle_permission_prompt +// │ +// (permission prompt) +// ▼ +// MCP helper child (--corral-claude-permission-tool, stdio JSON-RPC) +// │ net.Dial(unix) +// ▼ +// driver permission broker socket +// ▲ +// scheduler -> PermissionSession.RespondPermission +// +// The helper supplies its attempt and launch-session identity from the MCP +// process environment. The broker validates both, keeps the connection open +// until a decision arrives, and unblocks the helper (and Claude) exactly once. + +const ( + helperFlag = "--corral-claude-permission-tool" + permissionServerName = "corral" + permissionToolName = "mcp__corral__handle_permission_prompt" +) + +// mcpConfig is the --mcp-config document that wires the permission helper +// into claude as an MCP stdio server. +type mcpConfig struct { + Servers map[string]mcpServerConfig `json:"mcpServers"` +} + +type mcpServerConfig struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// helperExecutable is the binary claude spawns as the MCP permission helper: +// the driver's own executable. The embedding binary must forward +// --corral-claude-permission-tool to RunPermissionHelper (see the live test +// for a TestMain example). +func helperExecutable() string { + if exe, err := os.Executable(); err == nil { + return exe + } + return os.Args[0] +} + +// brokerRequest is the wire message sent by the helper to the broker. +type brokerRequest struct { + AttemptID string `json:"attempt_id"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` +} + +// decision is the broker's reply to a permission request. +type decision struct { + Allow bool `json:"allow"` + Message string `json:"message,omitempty"` +} + +func denyDecision(msg string) decision { + return decision{Allow: false, Message: msg} +} + +// permissionBroker answers permission requests from claude's MCP helper over +// a unix socket, one connection per pending request. +type permissionBroker struct { + d *Driver + ln net.Listener + path string + dir string // auto-created private directory; empty for an override path + done chan struct{} + mu sync.Mutex + pending map[permissionKey]chan decision + closed bool +} + +type permissionKey struct { + attemptID string + requestID string +} + +func (d *Driver) startBroker() error { + d.brokerOnce.Do(func() { + path := d.opts.PermissionSocket + dir := "" + if path == "" { + var err error + dir, err = os.MkdirTemp("", "corral-claude-") + if err != nil { + d.brokerErr = err + return + } + path = filepath.Join(dir, "broker.sock") + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if dir != "" { + _ = os.Remove(dir) + } + d.brokerErr = fmt.Errorf("remove stale socket: %w", err) + return + } + ln, err := net.Listen("unix", path) + if err != nil { + if dir != "" { + _ = os.Remove(dir) + } + d.brokerErr = err + return + } + if err := os.Chmod(path, 0o600); err != nil { + _ = ln.Close() + _ = os.Remove(path) + if dir != "" { + _ = os.Remove(dir) + } + d.brokerErr = fmt.Errorf("secure socket mode: %w", err) + return + } + b := &permissionBroker{ + d: d, + ln: ln, + path: path, + dir: dir, + done: make(chan struct{}), + pending: map[permissionKey]chan decision{}, + } + d.broker = b + go b.serve() + }) + return d.brokerErr +} + +func (b *permissionBroker) serve() { + for { + conn, err := b.ln.Accept() + if err != nil { + select { + case <-b.done: + return + default: + } + continue + } + go b.handle(conn) + } +} + +// handle serves one helper connection: receive the request, park it as the +// attempt's pending permission, and block until a decision is delivered. +func (b *permissionBroker) handle(conn net.Conn) { + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + var req brokerRequest + if err := dec.Decode(&req); err != nil || req.AttemptID == "" || req.SessionID == "" || req.RequestID == "" { + return + } + at := b.d.attemptByID(req.AttemptID) + if at == nil { + _ = enc.Encode(denyDecision("unknown attempt")) + return + } + at.mu.Lock() + validSession := req.SessionID == at.launchID || req.SessionID == at.sessionID + at.mu.Unlock() + if !validSession { + _ = enc.Encode(denyDecision("session does not belong to attempt")) + return + } + + ch := make(chan decision, 1) + key := permissionKey{attemptID: req.AttemptID, requestID: req.RequestID} + b.mu.Lock() + if b.closed { + b.mu.Unlock() + _ = enc.Encode(denyDecision("driver closed")) + return + } + at.mu.Lock() + if at.permission != "" && at.permission != req.RequestID { + at.mu.Unlock() + b.mu.Unlock() + _ = enc.Encode(denyDecision("another permission request is already pending")) + return + } + if _, exists := b.pending[key]; exists { + at.mu.Unlock() + b.mu.Unlock() + _ = enc.Encode(denyDecision("duplicate permission request")) + return + } + b.pending[key] = ch + at.permission = req.RequestID + at.mu.Unlock() + b.mu.Unlock() + + var reply decision + select { + case reply = <-ch: + case <-b.done: + reply = denyDecision("driver closed") + case <-time.After(10 * time.Minute): + reply = denyDecision("permission request timed out") + } + b.mu.Lock() + if b.pending[key] == ch { + delete(b.pending, key) + } + b.mu.Unlock() + at.mu.Lock() + if at.permission == req.RequestID { + at.permission = "" + } + at.mu.Unlock() + _ = enc.Encode(reply) +} + +// respond claims and resolves the helper waiting on requestID. Missing and +// duplicate responses are rejected instead of being silently accepted. +func (b *permissionBroker) respond(ctx context.Context, at *attempt, requestID string, allow bool) error { + if err := ctx.Err(); err != nil { + return err + } + key := permissionKey{attemptID: at.attemptID, requestID: requestID} + b.mu.Lock() + ch := b.pending[key] + if ch != nil { + delete(b.pending, key) // claim the request; duplicate responses now fail + } + b.mu.Unlock() + if ch == nil { + return fmt.Errorf("claudeadapter: permission %q has no waiting helper", requestID) + } + reply := decision{Allow: allow} + if !allow { + reply.Message = "denied by operator" + } + ch <- reply // buffered channel; cannot block + return nil +} + +func (b *permissionBroker) close() { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return + } + b.closed = true + b.mu.Unlock() + close(b.done) + _ = b.ln.Close() + _ = os.Remove(b.path) + if b.dir != "" { + _ = os.Remove(b.dir) + } +} + +// --------------------------------------------------------------------------- +// MCP stdio server (the --corral-claude-permission-tool helper). The helper +// is spawned by claude as an MCP server; each tools/call for the permission +// tool is forwarded to the driver's broker socket and the decision is +// returned as the tool result. + +// RunPermissionHelper serves the MCP permission-prompt tool over stdio. The +// embedding binary must invoke it when os.Args[1] == +// "--corral-claude-permission-tool" (the helper spawns with that flag). It +// returns only on error; callers exit afterwards. +func RunPermissionHelper() { + broker := os.Getenv("CORRAL_CLAUDE_BROKER") + if broker == "" { + fmt.Fprintln(os.Stderr, "corral claude permission helper: CORRAL_CLAUDE_BROKER not set") + os.Exit(1) + } + attemptID := os.Getenv("CORRAL_CLAUDE_ATTEMPT_ID") + sessionID := os.Getenv("CORRAL_CLAUDE_SESSION_ID") + if attemptID == "" || sessionID == "" { + fmt.Fprintln(os.Stderr, "corral claude permission helper: attempt/session identity not set") + os.Exit(1) + } + s := &mcpServer{broker: broker, attemptID: attemptID, sessionID: sessionID} + if err := s.serve(os.Stdin, os.Stdout); err != nil && !errors.Is(err, io.EOF) { + fmt.Fprintf(os.Stderr, "corral claude permission helper: %v\n", err) + os.Exit(1) + } +} + +type rpcRequest struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type mcpServer struct { + broker string + attemptID string + sessionID string + mu sync.Mutex +} + +func (s *mcpServer) serve(in io.Reader, out io.Writer) error { + enc := json.NewEncoder(out) + dec := json.NewDecoder(in) + for { + var req rpcRequest + if err := dec.Decode(&req); err != nil { + return err + } + switch req.Method { + case "initialize": + s.write(enc, req.ID, map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]string{"name": "corral", "version": "0.0.1"}, + }) + case "notifications/initialized": + // no response expected + case "tools/list": + s.write(enc, req.ID, map[string]any{ + "tools": []map[string]any{{ + "name": "handle_permission_prompt", + "description": "Corral handles Claude Code permission prompts on behalf of the scheduler.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "tool_name": map[string]string{"type": "string"}, + "input": map[string]string{"type": "object"}, + "tool_use_id": map[string]string{"type": "string"}, + }, + "required": []string{"tool_name", "input"}, + "additionalProperties": true, + }, + }}, + }) + case "tools/call": + go s.handleToolCall(enc, req.ID, req.Params) + case "ping": + s.write(enc, req.ID, map[string]any{}) + default: + s.writeErr(enc, req.ID, -32601, "unknown method "+req.Method) + } + } +} + +type toolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +// handleToolCall blocks until the driver answers the permission prompt, so +// it runs in its own goroutine; writes are serialized by the encoder mutex. +func (s *mcpServer) handleToolCall(enc *json.Encoder, id json.RawMessage, params json.RawMessage) { + var call toolCallParams + var args struct { + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"input"` + ToolUseID string `json:"tool_use_id"` + } + if json.Unmarshal(params, &call) != nil || call.Name != "handle_permission_prompt" { + s.writeToolError(enc, id, "unexpected tool call") + return + } + if err := json.Unmarshal(call.Arguments, &args); err != nil || args.ToolName == "" || len(args.ToolInput) == 0 || string(args.ToolInput) == "null" { + s.writeToolError(enc, id, "invalid permission prompt arguments") + return + } + requestID := args.ToolUseID + if requestID == "" { + requestID = newUUID() + } + + reply, err := s.askBroker(brokerRequest{ + AttemptID: s.attemptID, + SessionID: s.sessionID, + RequestID: requestID, + ToolName: args.ToolName, + ToolInput: args.ToolInput, + }) + if err != nil { + s.writeToolError(enc, id, err.Error()) + return + } + text, err := json.Marshal(decisionJSON(reply, args.ToolInput)) + if err != nil { + s.writeToolError(enc, id, err.Error()) + return + } + s.write(enc, id, map[string]any{ + "content": []map[string]string{{"type": "text", "text": string(text)}}, + "isError": false, + }) +} + +// decisionJSON renders a broker decision as the permission tool result +// claude expects: an allow must echo the tool input back. +func decisionJSON(d decision, toolInput json.RawMessage) map[string]any { + if d.Allow { + return map[string]any{"behavior": "allow", "updatedInput": rawOrEmpty(toolInput)} + } + return map[string]any{"behavior": "deny", "message": d.Message} +} + +func rawOrEmpty(raw json.RawMessage) any { + if len(raw) == 0 || strings.EqualFold(string(raw), "null") { + return map[string]any{} + } + return raw +} + +// askBroker forwards a permission request to the driver's broker socket and +// waits for the decision. +func (s *mcpServer) askBroker(req brokerRequest) (decision, error) { + conn, err := net.DialTimeout("unix", s.broker, 10*time.Second) + if err != nil { + return denyDecision(err.Error()), err + } + defer conn.Close() + if err := json.NewEncoder(conn).Encode(req); err != nil { + return denyDecision(err.Error()), err + } + var reply decision + if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&reply); err != nil { + return denyDecision(err.Error()), err + } + return reply, nil +} + +func (s *mcpServer) write(enc *json.Encoder, id json.RawMessage, result any) { + s.mu.Lock() + defer s.mu.Unlock() + _ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: id, Result: result}) +} + +func (s *mcpServer) writeErr(enc *json.Encoder, id json.RawMessage, code int, msg string) { + s.mu.Lock() + defer s.mu.Unlock() + _ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}) +} + +func (s *mcpServer) writeToolError(enc *json.Encoder, id json.RawMessage, msg string) { + s.write(enc, id, map[string]any{ + "content": []map[string]string{{"type": "text", "text": msg}}, + "isError": true, + }) +} From da0e964e9ff90c7ded23435e8451c0c26b56a66c Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Wed, 12 Aug 2026 00:44:03 -0300 Subject: [PATCH 24/26] fix: preserve Claude streams and usage --- internal/claudeadapter/adapter.go | 164 +++++++++++------ internal/claudeadapter/adapter_test.go | 234 ++++++++++++++++++++++++- 2 files changed, 341 insertions(+), 57 deletions(-) diff --git a/internal/claudeadapter/adapter.go b/internal/claudeadapter/adapter.go index 9fb52a2..5295412 100644 --- a/internal/claudeadapter/adapter.go +++ b/internal/claudeadapter/adapter.go @@ -12,9 +12,11 @@ package claudeadapter import ( "bufio" + "bytes" "context" "crypto/rand" "encoding/json" + "errors" "fmt" "io" "log" @@ -96,8 +98,8 @@ type attempt struct { errMsg string exited bool exitErr error - events chan streamEvent - exitedCh chan error // scanner -> watcher: the process Wait result + readErr error + exitedCh chan processExit // stream reader -> watcher after stdout is drained cancel context.CancelFunc mu sync.Mutex @@ -171,8 +173,7 @@ func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, sessionID: spec.sessionID, launchID: spec.sessionID, cwd: a.Cwd, - events: make(chan streamEvent, 256), - exitedCh: make(chan error, 1), + exitedCh: make(chan processExit, 1), cancel: cancel, } d.attempts[a.ID] = at @@ -297,24 +298,38 @@ func (at *attempt) cleanup() { at.d.mu.Unlock() } -// scan reads the claude stream-json output until EOF, dispatches each -// event to the attempt, then reports the process exit to the watcher. The -// exit is delivered through the watcher (not handled here) so the terminal -// decision always sees the full transcript in order. +type processExit struct { + waitErr error + readErr error +} + +// scan reads arbitrary-size stream-json records and handles them inline. This +// keeps transcript/result processing ordered and lossless without a bounded +// event queue. On a read failure it continues draining stdout before Wait so a +// child blocked on a full pipe cannot deadlock shutdown. func (d *Driver) scan(at *attempt) { - sc := bufio.NewScanner(at.proc.stdout()) - for sc.Scan() { - var ev streamEvent - if json.Unmarshal(sc.Bytes(), &ev) != nil { - continue // startup noise / unknown line + r := bufio.NewReader(at.proc.stdout()) + var readErr error + for { + line, err := r.ReadBytes('\n') + if len(bytes.TrimSpace(line)) > 0 { + var ev streamEvent + if json.Unmarshal(line, &ev) == nil { + at.handleEvent(ev) + } } - select { - case at.events <- ev: - default: // dropped; the process exit covers it + if err == nil { + continue + } + if !errors.Is(err, io.EOF) { + readErr = fmt.Errorf("read claude stream: %w", err) + _, _ = io.Copy(io.Discard, r) } + break } + exit := processExit{waitErr: at.proc.wait(), readErr: readErr} select { - case at.exitedCh <- at.proc.wait(): + case at.exitedCh <- exit: default: // watcher already gone (driver closed) } } @@ -332,24 +347,8 @@ func (at *attempt) watch(ctx context.Context) { return case <-poll.C: at.d.maybeComplete(context.Background(), at) - case ev := <-at.events: - at.handleEvent(ev) - case err := <-at.exitedCh: - at.drainEvents() - at.onExit(err) - return - } - } -} - -// drainEvents processes every event queued ahead of the exit signal so the -// terminal decision reflects the full transcript. -func (at *attempt) drainEvents() { - for { - select { - case ev := <-at.events: - at.handleEvent(ev) - default: + case exit := <-at.exitedCh: + at.onExit(exit) return } } @@ -381,7 +380,7 @@ func (at *attempt) handleEvent(ev streamEvent) { at.terminal = true at.subtype = ev.Subtype at.mu.Unlock() - at.d.maybeComplete(context.Background(), at) + at.applyResultAccounting(ev.TotalCostUSD, ev.Usage) case "stream_error": at.mu.Lock() at.terminal = true @@ -389,13 +388,12 @@ func (at *attempt) handleEvent(ev streamEvent) { at.errMsg = ev.Error } at.mu.Unlock() - at.d.maybeComplete(context.Background(), at) } } // onExit marks the process exit as the terminal event. It is the // reconciliation fallback when the result event was dropped. -func (at *attempt) onExit(err error) { +func (at *attempt) onExit(exit processExit) { at.mu.Lock() if at.stopped { at.mu.Unlock() @@ -403,7 +401,8 @@ func (at *attempt) onExit(err error) { } at.terminal = true at.exited = true - at.exitErr = err + at.exitErr = exit.waitErr + at.readErr = exit.readErr at.mu.Unlock() at.d.maybeComplete(context.Background(), at) } @@ -431,10 +430,7 @@ func (at *attempt) appendMessage(raw json.RawMessage, role string) { if err := json.Unmarshal(raw, &m); err != nil { return } - am := adapter.Message{Role: role, Finish: m.Stop, Cost: m.Cost} - if m.Usage != nil { - am.Tokens = m.Usage.InputTokens + m.Usage.OutputTokens - } + am := adapter.Message{Role: role, Finish: m.Stop} for _, c := range m.Content { var b contentBlock if json.Unmarshal(c, &b) != nil { @@ -452,6 +448,29 @@ func (at *attempt) appendMessage(raw json.RawMessage, role string) { at.mu.Unlock() } +// applyResultAccounting stores Claude's cumulative result totals exactly once +// on the final assistant message. The scheduler sums message accounting, so +// copying per-turn assistant usage as well would double-count a session. +func (at *attempt) applyResultAccounting(cost float64, usage *sdkUsage) { + if cost == 0 && usage == nil { + return + } + at.mu.Lock() + defer at.mu.Unlock() + for i := len(at.transcript) - 1; i >= 0; i-- { + if at.transcript[i].Role == "assistant" { + at.transcript[i].Cost = cost + at.transcript[i].Tokens = usage.total() + return + } + } + at.transcript = append(at.transcript, adapter.Message{ + Role: "assistant", + Cost: cost, + Tokens: usage.total(), + }) +} + func (at *attempt) snapshot() []adapter.Message { at.mu.Lock() defer at.mu.Unlock() @@ -475,9 +494,18 @@ func (at *attempt) terminalStatus() (adapter.Status, bool) { } at.mu.Lock() defer at.mu.Unlock() + if !at.exited { + return "", false + } if !at.terminal { return "", false } + if at.readErr != nil { + return adapter.StatusError, true + } + if at.exitErr != nil { + return adapter.StatusError, true + } if at.subtype == "success" { return adapter.StatusIdle, true } @@ -493,6 +521,24 @@ func (at *attempt) terminalStatus() (adapter.Status, bool) { return adapter.StatusIdle, true } +func (at *attempt) completionError() error { + at.mu.Lock() + defer at.mu.Unlock() + if at.readErr != nil { + return at.readErr + } + if at.errMsg != "" { + return errors.New(at.errMsg) + } + if at.exitErr != nil { + return at.exitErr + } + if at.subtype != "" && at.subtype != "success" { + return fmt.Errorf("claude result: %s", at.subtype) + } + return nil +} + // maybeComplete emits a completion exactly once per attempt, guarded // against duplicate or missing events. func (d *Driver) maybeComplete(ctx context.Context, at *attempt) { @@ -508,6 +554,7 @@ func (d *Driver) maybeComplete(ctx context.Context, at *attempt) { SessionID: at.currentSessionID(), Status: status, Messages: at.snapshot(), + Err: at.completionError(), } select { case d.completions <- c: @@ -661,26 +708,35 @@ func newUUID() string { // Claude Code stream-json protocol types. type streamEvent struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - SessionID string `json:"session_id"` - Message json.RawMessage `json:"message"` - Result string `json:"result"` - Error string `json:"error"` - Event json.RawMessage `json:"event"` + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + Message json.RawMessage `json:"message"` + Result string `json:"result"` + Error string `json:"error"` + Event json.RawMessage `json:"event"` + TotalCostUSD float64 `json:"total_cost_usd"` + Usage *sdkUsage `json:"usage"` } type sdkMessage struct { Role string `json:"role"` Content []json.RawMessage `json:"content"` - Usage *sdkUsage `json:"usage"` - Cost float64 `json:"cost"` Stop string `json:"stop_reason"` } type sdkUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` +} + +func (u *sdkUsage) total() int { + if u == nil { + return 0 + } + return u.InputTokens + u.OutputTokens + u.CacheCreationTokens + u.CacheReadTokens } type contentBlock struct { diff --git a/internal/claudeadapter/adapter_test.go b/internal/claudeadapter/adapter_test.go index a247621..16f597e 100644 --- a/internal/claudeadapter/adapter_test.go +++ b/internal/claudeadapter/adapter_test.go @@ -1,8 +1,10 @@ package claudeadapter import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" @@ -48,6 +50,76 @@ type fakeProc struct { waitErr error } +// faultReader emits valid stream data, one synthetic read error, then more +// bytes. drained closes only after a caller keeps reading through the fault. +type faultReader struct { + before []byte + after []byte + fault error + stage int + drained chan struct{} + once sync.Once +} + +func (r *faultReader) Read(p []byte) (int, error) { + switch r.stage { + case 0: + if len(r.before) > 0 { + n := copy(p, r.before) + r.before = r.before[n:] + return n, nil + } + r.stage++ + return 0, r.fault + case 1: + if len(r.after) > 0 { + n := copy(p, r.after) + r.after = r.after[n:] + if len(r.after) == 0 { + r.stage++ + r.once.Do(func() { close(r.drained) }) + } + return n, nil + } + r.stage++ + r.once.Do(func() { close(r.drained) }) + return 0, io.EOF + default: + r.once.Do(func() { close(r.drained) }) + return 0, io.EOF + } +} + +type faultProc struct { + r *faultReader + doneCh chan struct{} + doneOnce sync.Once +} + +func newFaultProc(before, after []byte, fault error) *faultProc { + drained := make(chan struct{}) + p := &faultProc{ + r: &faultReader{before: before, after: after, fault: fault, drained: drained}, + doneCh: make(chan struct{}), + } + go func() { + <-drained + p.doneOnce.Do(func() { close(p.doneCh) }) + }() + return p +} + +func (p *faultProc) stdout() io.Reader { return p.r } +func (p *faultProc) done() <-chan struct{} { return p.doneCh } +func (p *faultProc) wait() error { + <-p.doneCh + return nil +} +func (p *faultProc) signal(os.Signal) error { + p.doneOnce.Do(func() { close(p.doneCh) }) + return nil +} + func newFakeProc() (*fakeProc, *io.PipeReader) { r, w := io.Pipe() return &fakeProc{ @@ -240,7 +312,11 @@ func TestStartCompletion(t *testing.T) { "usage": map[string]any{"input_tokens": 5, "output_tokens": 3}, "content": []map[string]any{{"type": "text", "text": "Done. src/alpha.txt exists."}}, }}) - fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sid, "result": "Done."}) + fp.write(map[string]any{ + "type": "result", "subtype": "success", "session_id": sid, "result": "Done.", + "total_cost_usd": 0.004, + "usage": map[string]any{"input_tokens": 15, "output_tokens": 23}, + }) fp.finish(nil) cs := drainSteps(drv, 1) @@ -264,8 +340,8 @@ func TestStartCompletion(t *testing.T) { if msgs[0].Role != "assistant" || !strings.Contains(msgs[0].Text, "I will create the file.") { t.Errorf("msg[0] = %+v", msgs[0]) } - if msgs[0].Tokens != 30 { - t.Errorf("msg[0] tokens = %d, want 30", msgs[0].Tokens) + if msgs[0].Tokens != 0 || msgs[0].Cost != 0 { + t.Errorf("msg[0] has per-turn accounting: %+v", msgs[0]) } if msgs[1].Role != "user" || !strings.Contains(msgs[1].Text, "wrote src/alpha.txt") { t.Errorf("msg[1] = %+v", msgs[1]) @@ -273,6 +349,9 @@ func TestStartCompletion(t *testing.T) { if !strings.Contains(msgs[2].Text, "Done.") { t.Errorf("msg[2] = %+v", msgs[2]) } + if msgs[2].Tokens != 38 || msgs[2].Cost != 0.004 { + t.Errorf("msg[2] cumulative accounting = %+v", msgs[2]) + } // The session view exposes the same transcript. got, err := sess.Messages(context.Background()) @@ -321,6 +400,155 @@ func TestStartCompletion(t *testing.T) { } } +func TestLargeStreamJSONRecordIsNotLost(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + want := strings.Repeat("x", 256<<10) + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", "content": []map[string]any{{"type": "text", "text": want}}, + }}) + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 || cs[0].Status != adapter.StatusIdle { + t.Fatalf("completion = %+v, want one idle", cs) + } + if len(cs[0].Messages) != 1 || cs[0].Messages[0].Text != want { + t.Fatalf("large record transcript length = %d, text bytes = %d", len(cs[0].Messages), func() int { + if len(cs[0].Messages) == 0 { + return 0 + } + return len(cs[0].Messages[0].Text) + }()) + } +} + +func TestFloodedStreamPreservesEveryEventInOrder(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: time.Hour}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + const count = 2048 + for i := 0; i < count; i++ { + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "text", "text": fmt.Sprintf("%04d", i)}}, + }}) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("completions = %d, want 1", len(cs)) + } + if got := len(cs[0].Messages); got != count { + t.Fatalf("messages = %d, want %d", got, count) + } + for i, msg := range cs[0].Messages { + if want := fmt.Sprintf("%04d", i); msg.Text != want { + t.Fatalf("message[%d] = %q, want %q", i, msg.Text, want) + } + } +} + +func TestStreamReadErrorIsReportedAfterRemainingOutputIsDrained(t *testing.T) { + readErr := errors.New("synthetic stdout read failure") + first, _ := json.Marshal(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", "content": []map[string]any{{"type": "text", "text": "before fault"}}, + }}) + remainder := bytes.Repeat([]byte("drain-me"), 64<<10) + fp := newFaultProc(append(first, '\n'), remainder, readErr) + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = func(context.Context, spawnSpec) (process, error) { return fp, nil } + defer drv.Close() + + if _, err := drv.Start(context.Background(), attemptFor("w1/1", "task")); err != nil { + t.Fatal(err) + } + cs := drainSteps(drv, 1) + if len(cs) != 1 || cs[0].Status != adapter.StatusError { + t.Fatalf("completion = %+v, want one error", cs) + } + if !errors.Is(cs[0].Err, readErr) { + t.Fatalf("completion error = %v, want %v", cs[0].Err, readErr) + } + select { + case <-fp.r.drained: + default: + t.Fatal("stdout remainder was not drained after read error") + } +} + +func TestResultOwnsCumulativeCostAndUsageAccounting(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + // Real assistant envelopes contain per-turn usage but no cost. Corral uses + // the result envelope's cumulative totals exactly once for budget accounting. + for _, turn := range []struct { + text string + input int + output int + }{{"working", 10, 2}, {"done", 30, 5}} { + fp.write(map[string]any{"type": "assistant", "message": map[string]any{ + "role": "assistant", "content": []map[string]any{{"type": "text", "text": turn.text}}, + "usage": map[string]any{"input_tokens": turn.input, "output_tokens": turn.output}, + }}) + } + fp.write(map[string]any{ + "type": "result", "subtype": "success", "session_id": sess.ID(), + "total_cost_usd": 0.125, + "usage": map[string]any{ + "input_tokens": 100, "output_tokens": 20, + "cache_creation_input_tokens": 30, "cache_read_input_tokens": 40, + }, + }) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 || len(cs[0].Messages) != 2 { + t.Fatalf("completion = %+v", cs) + } + var cost float64 + var tokens int + for _, msg := range cs[0].Messages { + cost += msg.Cost + tokens += msg.Tokens + } + if cost != 0.125 { + t.Errorf("summed cost = %v, want 0.125", cost) + } + if tokens != 190 { + t.Errorf("summed tokens = %d, want 190", tokens) + } + if cs[0].Messages[0].Cost != 0 || cs[0].Messages[0].Tokens != 0 { + t.Errorf("first assistant message duplicated cumulative accounting: %+v", cs[0].Messages[0]) + } + if cs[0].Messages[1].Cost != 0.125 || cs[0].Messages[1].Tokens != 190 { + t.Errorf("final assistant accounting = %+v", cs[0].Messages[1]) + } +} + // TestExitFallbackCompletion verifies the reconciliation path: when the // terminal result event is missed the process exit decides the completion, // mapping exit 0 to idle and a non-zero exit to error. From 9c1fefb38a93a7afd2336785fdb9b47efddc6ce5 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Wed, 12 Aug 2026 02:11:16 -0300 Subject: [PATCH 25/26] fix: harden concurrent orchestration --- .opencode/tools/corral.ts | 15 +- README.md | 7 +- docs/task2-scheduler.md | 4 +- docs/task3-adapter.md | 27 +- docs/task4-verification.md | 23 +- docs/task6-plugin.md | 5 +- docs/task7-tui.md | 3 +- docs/task8-hardening.md | 7 +- example/opencode.json | 15 +- go.mod | 13 +- go.sum | 32 ++ internal/assets/assets_test.go | 6 + internal/assets/corral.ts | 15 +- internal/assets/opencode.json | 15 +- internal/claudeadapter/adapter.go | 351 ++++++++++++--- internal/claudeadapter/adapter_test.go | 517 ++++++++++++++++++++- internal/claudeadapter/permission.go | 19 +- internal/daemon/daemon.go | 45 +- internal/daemon/daemon_test.go | 132 ++++++ internal/daemon/events.go | 42 +- internal/daemon/events_reconcile_test.go | 65 +++ internal/ocx/client.go | 28 +- internal/ocx/client_test.go | 72 +++ internal/ocx/events.go | 13 +- internal/ocx/types.go | 45 +- internal/ocxadapter/adapter.go | 356 +++++++++++++-- internal/ocxadapter/adapter_test.go | 546 +++++++++++++++++++++++ internal/ocxadapter/terminal_test.go | 48 ++ internal/ocxreviewer/reviewer.go | 143 +++--- internal/ocxreviewer/reviewer_test.go | 109 ++++- internal/sched/hardening_test.go | 412 +++++++++++++++++ internal/sched/sched.go | 192 ++++++-- internal/store/redact_test.go | 29 ++ internal/store/store.go | 57 +++ internal/tui/notify_test.go | 19 +- internal/tui/tui_test.go | 77 ++++ internal/tui/view.go | 169 +++++-- internal/worktree/worktree.go | 69 ++- internal/worktree/worktree_test.go | 247 +++++++++- 39 files changed, 3571 insertions(+), 418 deletions(-) create mode 100644 internal/daemon/events_reconcile_test.go create mode 100644 internal/ocx/client_test.go create mode 100644 internal/ocxadapter/terminal_test.go diff --git a/.opencode/tools/corral.ts b/.opencode/tools/corral.ts index 000a661..38d22a6 100644 --- a/.opencode/tools/corral.ts +++ b/.opencode/tools/corral.ts @@ -18,7 +18,8 @@ async function loadKey(): Promise { } // Map the current OpenCode agent to a corral role for server-side -// enforcement. Anything unknown falls back to operator (human). +// enforcement. Unknown agents stay unprivileged; only non-model clients such +// as the CLI/TUI may claim the operator role directly. const ROLE_MAP: Record = { "corral-orchestrator": "orchestrator", "corral-planner": "planner", @@ -29,7 +30,7 @@ const ROLE_MAP: Record = { function roleFor(agent?: string): string { if (agent && agent in ROLE_MAP) return ROLE_MAP[agent] - return "operator" + return "unknown" } async function call(path: string, body?: unknown, role?: string) { @@ -40,7 +41,7 @@ async function call(path: string, body?: unknown, role?: string) { method: body === undefined ? "GET" : "POST", headers: { "Content-Type": "application/json", - "X-Corral-Role": role ?? "operator", + "X-Corral-Role": role ?? "unknown", ...(key ? { Authorization: `Bearer ${key}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), @@ -72,12 +73,16 @@ export const start = tool({ autoApproveGates: tool.schema.boolean().optional().describe("When true, the run is pre-authorized: the orchestrator approves human gates itself as they are reached, without waiting for the operator"), }, async execute(args, context) { - let graph: unknown + let parsed: unknown try { - graph = JSON.parse(args.graph) + parsed = JSON.parse(args.graph) } catch { return "error: graph is not valid JSON" } + const graph = + typeof parsed === "object" && parsed !== null && "graph" in parsed + ? (parsed as { graph: unknown }).graph + : parsed const body: Record = { graph } if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates return call("/api/runs", body, roleFor(context.agent)) diff --git a/README.md b/README.md index e5b19a6..f337eec 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,10 @@ Corral currently wires four completion paths: reported by the driver. Prose alone fails. - **Reviewer:** a read-only OpenCode session reviews the attempt's evidence — objective, prior feedback, transcript, the recorded diff artifact, and check - results — and must conclude `APPROVED`; a `NOT_APPROVED` verdict returns its - note as focused retry feedback. Set `CORRAL_REVIEWER_MODEL` to use a specific - model for review sessions. + results — and must return exactly `APPROVED` or `CHANGES_REQUESTED`, followed + by a required `Note:` line. A change request returns its note as focused + retry feedback. Set `CORRAL_REVIEWER_MODEL` to a `provider/model` value to + use a specific model for review sessions. ## Proof, not promises diff --git a/docs/task2-scheduler.md b/docs/task2-scheduler.md index 7095d45..58a705d 100644 --- a/docs/task2-scheduler.md +++ b/docs/task2-scheduler.md @@ -18,8 +18,8 @@ replay reproduces graph state, crash/restart test passes. `ClearLease` is for crash recovery. - `internal/sched` — the control loop: - One deterministic `Step()` per tick; all state mutation happens inside - it (cooperative simulation with the fake driver, async results channel - for real drivers). + it (cooperative simulation with the fake driver; real-driver completions + are drained and routed centrally by the scheduler). - Concurrency limit, priority ordering with **aging** (boost per saturated step, capped; only waiting nodes accrue). - Retry policy: `verifying → retry_wait → ready` on failed evidence while diff --git a/docs/task3-adapter.md b/docs/task3-adapter.md index dabfbb2..4fa8588 100644 --- a/docs/task3-adapter.md +++ b/docs/task3-adapter.md @@ -8,26 +8,35 @@ sessions; integration test covers parallel execution and cancellation. - `internal/ocxadapter` — `Driver` implementing `adapter.Driver` + `adapter.Stepper`: - `Start`: `POST /session` (title `corral/`), async prompt with the - node objective (+ role prefix, optional model override), records the - OpenCode session; returns an `adapter.Session` handle. + node objective (+ role prefix). A per-attempt `provider/model` overrides + the driver default; records the OpenCode session and returns an + `adapter.Session` handle. - Completion detection: a shared `/global/event` SSE stream (opened once per driver) dispatches `session.idle` / `session.error` to the owning attempt's event channel; a per-attempt poller (`PollInterval`, default - 1s) queries `/session/status` + `/session/:id/message` as the - reconciliation fallback when events are missed or the stream drops. + 1s) queries `/session/:id/message` as the reconciliation fallback when + terminal events are missed or the stream drops. The first start waits at + most `StreamReadyTimeout` (default 1s) for SSE, then proceeds through REST + fallback instead of hanging when `/global/event` is unavailable. - Exactly-once completion: `attempt.completed` guard (checked before the transcript fetch and again under the driver mutex before emission); duplicate events (e.g. repeated `session.idle`) cannot emit twice, and the scheduler asserts exactly one attempt row per node. - Terminal classification from the transcript: aborted flag or `MessageAbortedError` → `StatusAborted`; other assistant error → - `StatusError`; `finish:"stop"` → `StatusIdle`; no finished message → - still running (poll again). + `StatusError`; `finish:"stop"` → `StatusIdle`; `finish:"tool-calls"` → + still running; any other non-empty finish reason → `StatusError`. - `Abort` → `POST /session/:id/abort`; `Messages` → adapter messages with text parts, costs/tokens, and user-summary diffs (patch format). + - Permission requests use `permission.asked` / `permission.v2.asked` for + low-latency notification plus one shared, bounded background + `GET /permission` reconciler; scheduler permission checks remain local and + non-blocking. Decisions use `POST /permission/:requestID/reply` with + `once` / `reject`. - Contract additions (adapter v0.2): `adapter.Completion`, `adapter.Stepper`, `Session.ServerID()`. -- `sched`: drains `adapter.Stepper` into the results channel; records +- `sched`: drains the shared `adapter.Stepper` through a central owner router + so concurrent runs receive only their own completions; records `ServerID` per attempt (`store.Attempt.ServerID`, new `server_id` column); new `RunHandle.CancelNode` (operator cancel → driver abort → `running → canceled`). @@ -52,8 +61,8 @@ sessions; integration test covers parallel execution and cancellation. |---|---| | Start, stream, message, inspect, cancel sessions | Integration test: sessions created + prompted; SSE stream dispatches terminal events; transcripts fetched via `Messages`; `CancelNode` aborts mid-run | | Record OpenCode server/session IDs per attempt | Asserted: `session_id` has `ses_` prefix, `server_id` == server base URL | -| Event stream + status polling fallback | Both paths implemented; polling is the completion path when events are dropped (same `maybeComplete` logic) | -| Duplicate/missing events cannot duplicate completion | Exactly 1 attempt row per node asserted; `completed` guard + scheduler drop of unknown/duplicate attempt results | +| Event stream + REST fallback | Startup is bounded when SSE is unavailable; transcript polling completes attempts, and durable permission polling recovers prompts missed during reconnects | +| Duplicate/missing events cannot duplicate completion | Exactly 1 attempt row per node asserted; `completed` guard + scheduler owner routing retains start-race completions until registration | | Two-node parallel run + cancellation test | Both sessions observed busy concurrently (peak ≥ 2); w1 done with real file output; w2 canceled with `aborted` attempt | | Claude protocol and permission mediation | Protocol fixtures cover init/result/usage, large and high-volume streams, duplicate terminal events, scoped MCP decisions, aborts, and closed-driver behavior; race and cross-build checks cover the package | diff --git a/docs/task4-verification.md b/docs/task4-verification.md index 413109d..4f9c382 100644 --- a/docs/task4-verification.md +++ b/docs/task4-verification.md @@ -17,8 +17,10 @@ focused feedback; budgets bound retries; prose alone never completes work. - **reviewer**: `internal/ocxreviewer` implements the injectable `Reviewer` seam on top of OpenCode: a read-only LLM session receives the attempt's evidence (objective, prior feedback, transcript, recorded diff - artifact and check results), must conclude APPROVED; the rejection note - is the feedback. `CORRAL_REVIEWER_MODEL` overrides the session model. + artifact and check results), must return exactly `APPROVED` or + `CHANGES_REQUESTED` followed by a required `Note:` line; the change-request + note is the feedback. `CORRAL_REVIEWER_MODEL` overrides the session model + using OpenCode's `provider/model` format. - **default gate** (no method declared): an attempt must have produced at least one diff — agent prose alone cannot mark work complete. - **check nodes**: verdict derived from their own command run carried in @@ -41,7 +43,7 @@ focused feedback; budgets bound retries; prose alone never completes work. | Criterion | Evidence | |---|---| | Command, JSON-schema, reviewer checks | `verify` unit tests: pass/fail + feedback for each kind; check-node verdict from Meta | -| Reviewer approves / rejects with a note | `ocxreviewer` tests: scripted fake LLM server covers APPROVED, NOT_APPROVED with notes, session errors, missing verdicts, timeout; `TestOpenCodeReviewerLive` runs a real review session (gated on `CORRAL_LIVE`) | +| Reviewer approves / requests changes with a note | `ocxreviewer` tests: scripted fake LLM server covers exact APPROVED and CHANGES_REQUESTED verdicts with notes, malformed verdicts, session errors, missing verdicts, timeout; `TestOpenCodeReviewerLive` runs a real review session (gated on `CORRAL_LIVE`) | | Failed verification returns focused feedback | `TestFailThenPassAfterRetryWithFeedback`: gate 1 stderr reaches attempt 2 verbatim | | Retry count, timeout, budget bounded | retries from policy; time budget aborts (Task 2); `TestTokenBudgetBoundsRetries` stops retries after MaxTokens consumed | | Exhausted node becomes blocked or failed | `TestPermanentFailureBlocksDownstream`: failed, dependent blocked, run settles `waiting`, dependent never ran | @@ -55,9 +57,12 @@ focused feedback; budgets bound retries; prose alone never completes work. intervention (used by later tasks). - `TestOpenCodeEvidenceGates` is deterministic because the gates grep for fixed markers the prompt demands, independent of model behavior. -- Reviewer sessions are read-only (shell and write tools removed), review - recorded diffs and command results from the evidence prompt, poll the - transcript to idle, and parse the verdict from the last assistant message. - The verdict format is fixed in the prompt: - `APPROVED`/`NOT_APPROVED` plus a `Note:` line; anything else fails the - gate with a parse error. +- Reviewer sessions run as the named `corral-reviewer` agent with an agent-level + wildcard deny at both named-agent and prompt permission layers, + review recorded diffs and command results from the evidence + prompt, and stay bound to the main OpenCode project where that named agent + is configured (they never access the attempt worktree). They poll the + transcript to idle and parse the verdict from the last assistant message. + The verdict must contain exactly two lines: + `APPROVED`/`CHANGES_REQUESTED` then a non-empty `Note:` line; anything else + fails the gate with a parse error. diff --git a/docs/task6-plugin.md b/docs/task6-plugin.md index f5aa36d..bcdd9e7 100644 --- a/docs/task6-plugin.md +++ b/docs/task6-plugin.md @@ -47,7 +47,7 @@ flow is verified end-to-end against a real OpenCode server. - `example/opencode.json` — agent role configuration using OpenCode's per-agent permissions: orchestrator (deny edit/bash, allow corral_*), planner (read-only + corral_plan), worker (ask edits/bash), reviewer - (deny edit; bash allow only `git diff/status/log`, tests), merger (deny + (deny all tools; evaluates supplied evidence only), merger (deny edit; bash allow only `git status/log/diff`, ask merge/checkout/branch). ## Acceptance verification @@ -64,7 +64,8 @@ flow is verified end-to-end against a real OpenCode server. - Planner smoke is tolerant: if the model fails to emit a parseable graph it logs and skips (nondeterministic LLM output); normalization keeps drift recoverable. -- The plugin's role fallback is `operator` for unknown agents (human). +- The plugin's role fallback is unprivileged `unknown`; operator authority is + reserved for non-model CLI/TUI clients. - `steer` sends a message into the running session (agent sees it as a follow-up instruction). - Run: `go test ./internal/daemon -v` (includes the real-OpenCode E2E). diff --git a/docs/task7-tui.md b/docs/task7-tui.md index 7d0b875..9068912 100644 --- a/docs/task7-tui.md +++ b/docs/task7-tui.md @@ -15,7 +15,8 @@ is the companion observability surface (no OpenCode fork needed). terminal. Modes: list → detail → inspect → steer. Keys: - list: `↑/↓` (or j/k), `enter` detail, `q` quit - detail: `↑/↓` node, `a` approve, `r` reject, `c` cancel, `t` retry, - `s` steer (typed message, enter sends), `i` inspect, `esc` back + `p` allow permission, `d` deny permission, `s` steer (typed message, + enter sends), `i` inspect, `esc` back - inspect: attempts (status, session, worktree, elapsed, cost/tokens, evidence) plus the active attempt's live transcript tail, `esc` back - `internal/tui/view.go` — lipgloss rendering: diff --git a/docs/task8-hardening.md b/docs/task8-hardening.md index bc6605c..1c7b7fd 100644 --- a/docs/task8-hardening.md +++ b/docs/task8-hardening.md @@ -7,8 +7,11 @@ Status: **DONE** — all acceptance criteria covered with tests. - **Permission requests → explicit blocked state** - `adapter.PermissionSession` (optional interface): `PendingPermission`, `RespondPermission`. - - `ocxadapter` tracks `permission.updated` events per session and answers - via `POST /session/:id/permissions/:permissionID`. + - `ocxadapter` tracks `permission.asked` / `permission.v2.asked`; one shared, + bounded background poll queries durable `GET /permission` across SSE + reconnect gaps, keeping scheduler `PendingPermission` checks local and + non-blocking. Answers use `POST /permission/:requestID/reply` + (`once` / `reject`). - Scheduler: a pending permission moves the node `running → blocked` (payload carries `permissionID`) and the session is *suspended* — its eventual completion still resolves the attempt (machine walk diff --git a/example/opencode.json b/example/opencode.json index edbdb6e..4237527 100644 --- a/example/opencode.json +++ b/example/opencode.json @@ -39,20 +39,11 @@ } }, "corral-reviewer": { - "description": "Corral reviewer: read-only analysis plus tests and diffs.", + "description": "Corral reviewer: evaluates supplied evidence without tools.", "mode": "subagent", - "prompt": "You are a corral reviewer. Inspect the worker's output read-only; you may run tests and inspect diffs, but never modify files.", + "prompt": "You are a corral reviewer. Evaluate only the evidence supplied in the prompt. Never call tools or modify external state.", "permission": { - "edit": "deny", - "bash": { - "*": "deny", - "git diff*": "allow", - "git status*": "allow", - "git log*": "allow", - "go test*": "allow", - "npm test*": "allow", - "pytest*": "allow" - } + "*": "deny" } }, "corral-merger": { diff --git a/go.mod b/go.mod index 62ec82a..e713e85 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,17 @@ module corral go 1.26.5 +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + modernc.org/sqlite v1.56.0 +) + require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -23,12 +28,10 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.3.8 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.56.0 // indirect ) diff --git a/go.sum b/go.sum index 406a95f..e2a4061 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -43,16 +47,44 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6Ng github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/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.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= 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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/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.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +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/assets/assets_test.go b/internal/assets/assets_test.go index 328a081..d28d0f6 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -33,6 +33,12 @@ func TestEmbeddedContentValid(t *testing.T) { if !contains(OpenCodeConfigJSON, `"corral-orchestrator"`) || !contains(OpenCodeConfigJSON, `"corral-planner"`) { t.Error("embedded config missing agents") } + if !contains(CorralPluginTS, `"graph" in parsed`) || !contains(CorralPluginTS, `(parsed as { graph: unknown }).graph`) { + t.Error("embedded start tool does not unwrap full corral_plan output") + } + if contains(CorralPluginTS, `return "operator"`) || contains(CorralPluginTS, `role ?? "operator"`) { + t.Error("embedded plugin lets unknown model agents mint operator authority") + } } func contains(s, sub string) bool { diff --git a/internal/assets/corral.ts b/internal/assets/corral.ts index 000a661..38d22a6 100644 --- a/internal/assets/corral.ts +++ b/internal/assets/corral.ts @@ -18,7 +18,8 @@ async function loadKey(): Promise { } // Map the current OpenCode agent to a corral role for server-side -// enforcement. Anything unknown falls back to operator (human). +// enforcement. Unknown agents stay unprivileged; only non-model clients such +// as the CLI/TUI may claim the operator role directly. const ROLE_MAP: Record = { "corral-orchestrator": "orchestrator", "corral-planner": "planner", @@ -29,7 +30,7 @@ const ROLE_MAP: Record = { function roleFor(agent?: string): string { if (agent && agent in ROLE_MAP) return ROLE_MAP[agent] - return "operator" + return "unknown" } async function call(path: string, body?: unknown, role?: string) { @@ -40,7 +41,7 @@ async function call(path: string, body?: unknown, role?: string) { method: body === undefined ? "GET" : "POST", headers: { "Content-Type": "application/json", - "X-Corral-Role": role ?? "operator", + "X-Corral-Role": role ?? "unknown", ...(key ? { Authorization: `Bearer ${key}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), @@ -72,12 +73,16 @@ export const start = tool({ autoApproveGates: tool.schema.boolean().optional().describe("When true, the run is pre-authorized: the orchestrator approves human gates itself as they are reached, without waiting for the operator"), }, async execute(args, context) { - let graph: unknown + let parsed: unknown try { - graph = JSON.parse(args.graph) + parsed = JSON.parse(args.graph) } catch { return "error: graph is not valid JSON" } + const graph = + typeof parsed === "object" && parsed !== null && "graph" in parsed + ? (parsed as { graph: unknown }).graph + : parsed const body: Record = { graph } if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates return call("/api/runs", body, roleFor(context.agent)) diff --git a/internal/assets/opencode.json b/internal/assets/opencode.json index edbdb6e..4237527 100644 --- a/internal/assets/opencode.json +++ b/internal/assets/opencode.json @@ -39,20 +39,11 @@ } }, "corral-reviewer": { - "description": "Corral reviewer: read-only analysis plus tests and diffs.", + "description": "Corral reviewer: evaluates supplied evidence without tools.", "mode": "subagent", - "prompt": "You are a corral reviewer. Inspect the worker's output read-only; you may run tests and inspect diffs, but never modify files.", + "prompt": "You are a corral reviewer. Evaluate only the evidence supplied in the prompt. Never call tools or modify external state.", "permission": { - "edit": "deny", - "bash": { - "*": "deny", - "git diff*": "allow", - "git status*": "allow", - "git log*": "allow", - "go test*": "allow", - "npm test*": "allow", - "pytest*": "allow" - } + "*": "deny" } }, "corral-merger": { diff --git a/internal/claudeadapter/adapter.go b/internal/claudeadapter/adapter.go index 5295412..fa24f58 100644 --- a/internal/claudeadapter/adapter.go +++ b/internal/claudeadapter/adapter.go @@ -19,9 +19,10 @@ import ( "errors" "fmt" "io" - "log" "os" "os/exec" + "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -71,7 +72,8 @@ type Driver struct { mu sync.Mutex attempts map[string]*attempt // attemptID -> rec bySession map[string]*attempt // sessionID -> rec - completions chan adapter.Completion + seen map[string]struct{} // all successfully started attempt IDs + completions []adapter.Completion spawn spawnFunc brokerOnce sync.Once @@ -109,11 +111,11 @@ type attempt struct { func New(opts Options) *Driver { return &Driver{ - opts: opts, - attempts: map[string]*attempt{}, - bySession: map[string]*attempt{}, - completions: make(chan adapter.Completion, 64), - spawn: spawnCLI, + opts: opts, + attempts: map[string]*attempt{}, + bySession: map[string]*attempt{}, + seen: map[string]struct{}{}, + spawn: spawnCLI, } } @@ -148,7 +150,7 @@ func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, d.mu.Unlock() return nil, fmt.Errorf("start claude: driver is closed") } - if _, exists := d.attempts[a.ID]; exists { + if _, exists := d.seen[a.ID]; exists { d.mu.Unlock() return nil, fmt.Errorf("start claude: attempt %q already started", a.ID) } @@ -178,6 +180,7 @@ func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, } d.attempts[a.ID] = at d.bySession[spec.sessionID] = at + d.seen[a.ID] = struct{}{} d.mu.Unlock() go d.scan(at) @@ -246,15 +249,11 @@ func (d *Driver) modelFor(a adapter.Attempt) string { // Step drains completed attempts (non-blocking). func (d *Driver) Step(_ context.Context, _ time.Time) []adapter.Completion { - var out []adapter.Completion - for { - select { - case c := <-d.completions: - out = append(out, c) - default: - return out - } - } + d.mu.Lock() + defer d.mu.Unlock() + out := d.completions + d.completions = nil + return out } // attemptBySession returns the attempt owning a session id, if any. @@ -308,7 +307,8 @@ type processExit struct { // event queue. On a read failure it continues draining stdout before Wait so a // child blocked on a full pipe cannot deadlock shutdown. func (d *Driver) scan(at *attempt) { - r := bufio.NewReader(at.proc.stdout()) + stdout := at.proc.stdout() + r := bufio.NewReader(stdout) var readErr error for { line, err := r.ReadBytes('\n') @@ -328,6 +328,11 @@ func (d *Driver) scan(at *attempt) { break } exit := processExit{waitErr: at.proc.wait(), readErr: readErr} + // Release the parent read descriptor only after all output is drained, but + // before publishing process exit so completion implies stdout is closed. + if closer, ok := stdout.(io.Closer); ok { + _ = closer.Close() + } select { case at.exitedCh <- exit: default: // watcher already gone (driver closed) @@ -337,13 +342,13 @@ func (d *Driver) scan(at *attempt) { // watch drives an attempt to a terminal state. The event stream is the // primary signal; a poll ticker is the reconciliation fallback. func (at *attempt) watch(ctx context.Context) { + defer at.cleanup() poll := time.NewTicker(at.d.opts.poll()) defer poll.Stop() for { select { case <-ctx.Done(): at.terminate() - at.cleanup() return case <-poll.C: at.d.maybeComplete(context.Background(), at) @@ -417,7 +422,7 @@ func (at *attempt) terminate() { return default: } - _ = at.proc.signal(syscall.SIGKILL) + _ = at.proc.signal(os.Kill) select { case <-at.proc.done(): case <-time.After(2 * time.Second): @@ -479,6 +484,18 @@ func (at *attempt) snapshot() []adapter.Message { return out } +func (at *attempt) appendDiffs(diffs []adapter.Diff) { + if len(diffs) == 0 { + return + } + at.mu.Lock() + defer at.mu.Unlock() + // OpenCode exposes authoritative diff summaries on user messages. Keep + // the same adapter contract for Claude so default verification and + // reviewers consume provider-independent evidence. + at.transcript = append(at.transcript, adapter.Message{Role: "user", Diffs: diffs}) +} + func (at *attempt) currentSessionID() string { at.mu.Lock() defer at.mu.Unlock() @@ -489,14 +506,14 @@ func (at *attempt) currentSessionID() string { // what adapter.Status it maps to. Aborted attempts are terminal // immediately; otherwise a result/error/exit event must have been seen. func (at *attempt) terminalStatus() (adapter.Status, bool) { - if at.aborted.Load() { - return adapter.StatusAborted, true - } at.mu.Lock() defer at.mu.Unlock() if !at.exited { return "", false } + if at.aborted.Load() { + return adapter.StatusAborted, true + } if !at.terminal { return "", false } @@ -549,20 +566,208 @@ func (d *Driver) maybeComplete(ctx context.Context, at *attempt) { if !at.completed.CompareAndSwap(false, true) { return // duplicate event; already handled } + completionErr := at.completionError() + if status == adapter.StatusIdle { + diffs, err := captureGitDiffs(ctx, at.cwd) + if err != nil { + // Verification must never receive a successful completion carrying + // partial or missing evidence for a Git worktree. A plain non-Git + // cwd is intentionally exempt and returns (nil, nil) below. + status = adapter.StatusError + completionErr = errors.Join(completionErr, fmt.Errorf("capture git diff evidence: %w", err)) + } else { + at.appendDiffs(diffs) + } + } c := adapter.Completion{ AttemptID: at.attemptID, SessionID: at.currentSessionID(), Status: status, Messages: at.snapshot(), - Err: at.completionError(), + Err: completionErr, } - select { - case d.completions <- c: + d.mu.Lock() + if !d.closed { + d.completions = append(d.completions, c) + } + d.mu.Unlock() +} + +// captureGitDiffs reads all tracked and untracked worktree changes against +// HEAD without touching the index. Attempts outside a Git worktree (or with +// no changes) simply provide no evidence. Once cwd is confirmed as a Git +// worktree, collection is atomic: any command or parse failure returns no +// diffs and an error so verification fails closed instead of seeing a partial +// change set. +func captureGitDiffs(ctx context.Context, cwd string) ([]adapter.Diff, error) { + if strings.TrimSpace(cwd) == "" { + return nil, nil + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + inside, err := gitOutput(ctx, cwd, false, "rev-parse", "--is-inside-work-tree") + if err != nil { + if isNotGitRepository(err) { + return nil, nil + } + return nil, err + } + if strings.TrimSpace(string(inside)) != "true" { + return nil, nil + } + statusOut, err := gitOutput(ctx, cwd, false, + "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-status", "-z", "HEAD", "--") + if err != nil { + return nil, err + } + + statuses := map[string]string{} + untrackedPaths := map[string]bool{} + fields := splitNUL(statusOut) + if len(fields)%2 != 0 { + return nil, fmt.Errorf("parse git name-status: odd field count %d", len(fields)) + } + for i := 0; i+1 < len(fields); i += 2 { + statuses[fields[i+1]] = diffStatus(fields[i]) + } + untracked, err := gitOutput(ctx, cwd, false, + "ls-files", "--others", "--exclude-standard", "-z", "--") + if err != nil { + return nil, err + } + for _, path := range splitNUL(untracked) { + statuses[path] = "added" + untrackedPaths[path] = true + } + + paths := make([]string, 0, len(statuses)) + for path := range statuses { + paths = append(paths, path) + } + sort.Strings(paths) + diffs := make([]adapter.Diff, 0, len(paths)) + for _, path := range paths { + untracked := untrackedPaths[path] + args := []string{"diff", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", "--no-renames"} + if untracked { + args = append(args, "--no-index", "--", "/dev/null", path) + } else { + args = append(args, "HEAD", "--", path) + } + patch, err := gitOutput(ctx, cwd, untracked, args...) + if err != nil { + return nil, err + } + if len(patch) == 0 { + return nil, fmt.Errorf("git returned no patch for changed path %q", path) + } + + statArgs := []string{"diff", "--no-ext-diff", "--no-textconv", "--numstat", "-z", "--no-renames"} + if untracked { + statArgs = append(statArgs, "--no-index", "--", "/dev/null", path) + } else { + statArgs = append(statArgs, "HEAD", "--", path) + } + stat, err := gitOutput(ctx, cwd, untracked, statArgs...) + if err != nil { + return nil, err + } + additions, deletions, err := parseNumstat(stat) + if err != nil { + return nil, fmt.Errorf("parse git numstat for %q: %w", path, err) + } + diffs = append(diffs, adapter.Diff{ + File: path, Patch: string(patch), Additions: additions, + Deletions: deletions, Status: statuses[path], + }) + } + return diffs, nil +} + +func gitOutput(ctx context.Context, cwd string, allowDiffExit bool, args ...string) ([]byte, error) { + displayArgs := append([]string(nil), args...) + args = append([]string{"-c", "core.fsmonitor=false"}, args...) + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = cwd + cmd.Env = append(os.Environ(), "LC_ALL=C") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err == nil { + return out, nil + } + var exitErr *exec.ExitError + if allowDiffExit && errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return out, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + detail := strings.TrimSpace(stderr.String()) + if detail != "" { + return nil, fmt.Errorf("git %s: %w: %s", strings.Join(displayArgs, " "), err, detail) + } + return nil, fmt.Errorf("git %s: %w", strings.Join(displayArgs, " "), err) +} + +func isNotGitRepository(err error) bool { + return strings.Contains(err.Error(), "not a git repository") +} + +func splitNUL(data []byte) []string { + raw := bytes.Split(data, []byte{0}) + out := make([]string, 0, len(raw)) + for _, field := range raw { + if len(field) > 0 { + out = append(out, string(field)) + } + } + return out +} + +func diffStatus(code string) string { + switch { + case strings.HasPrefix(code, "A"): + return "added" + case strings.HasPrefix(code, "D"): + return "deleted" default: - log.Printf("claudeadapter: completion channel full; dropping %s", at.attemptID) + return "modified" } } +func parseNumstat(data []byte) (additions, deletions int, err error) { + line := data + if i := bytes.IndexByte(line, 0); i >= 0 { + line = line[:i] + } + first := bytes.IndexByte(line, '\t') + if first < 0 { + return 0, 0, fmt.Errorf("missing additions field") + } + secondRel := bytes.IndexByte(line[first+1:], '\t') + if secondRel < 0 { + return 0, 0, fmt.Errorf("missing deletions field") + } + second := first + 1 + secondRel + parse := func(field []byte) (int, error) { + if bytes.Equal(field, []byte("-")) { // binary diff + return 0, nil + } + return strconv.Atoi(string(field)) + } + additions, err = parse(line[:first]) + if err != nil { + return 0, 0, fmt.Errorf("invalid additions: %w", err) + } + deletions, err = parse(line[first+1 : second]) + if err != nil { + return 0, 0, fmt.Errorf("invalid deletions: %w", err) + } + return additions, deletions, nil +} + // session implements adapter.Session and adapter.PermissionSession for a // live claude session. type session struct { @@ -583,25 +788,41 @@ func (s *session) Send(_ context.Context, text string) error { } func (s *session) Abort(ctx context.Context) error { - s.at.aborted.Store(true) + // Serialize successful signal delivery with onExit/terminalStatus. This + // prevents a fast process exit from publishing an idle completion between + // signal delivery and marking the attempt aborted, while still ensuring + // failed signals never falsify the provider state. + s.at.mu.Lock() select { case <-s.at.proc.done(): - return nil // already exited + s.at.mu.Unlock() + return nil // already exited naturally; preserve its terminal status default: } // SIGTERM is Claude Code's graceful stop (it aborts the turn, runs - // SessionEnd hooks and exits 143); SIGKILL is the hard fallback. - if err := s.at.proc.signal(syscall.SIGTERM); err != nil { - return err + // SessionEnd hooks and exits 143). Some platforms only implement Kill. + if termErr := s.at.proc.signal(syscall.SIGTERM); termErr != nil { + if killErr := s.at.proc.signal(os.Kill); killErr != nil { + select { + case <-s.at.proc.done(): + s.at.mu.Unlock() + return nil // raced with natural exit; neither signal succeeded + default: + } + s.at.mu.Unlock() + return errors.Join(termErr, killErr) + } } + s.at.aborted.Store(true) + s.at.mu.Unlock() select { case <-s.at.proc.done(): return nil case <-ctx.Done(): - _ = s.at.proc.signal(syscall.SIGKILL) + _ = s.at.proc.signal(os.Kill) return ctx.Err() case <-time.After(5 * time.Second): - return s.at.proc.signal(syscall.SIGKILL) + return s.at.proc.signal(os.Kill) } } @@ -679,13 +900,44 @@ func promptFor(a adapter.Attempt) string { return b.String() } -// allowedTools maps an attempt's write scope onto Claude Code permission -// rules: read-only tools are always approved and scoped Edit rules cover -// each writable path, so in-scope work runs without prompts. +// allowedTools maps a writing attempt's scope onto Claude Code permission +// rules. Non-writing roles stay read-only even if a malformed graph supplies +// a write scope; role is the scheduler's authority boundary. func allowedTools(a adapter.Attempt) []string { tools := []string{"Read", "Glob", "Grep"} + if a.Role != "" && a.Role != "worker" { + return tools + } + seen := map[string]bool{"Read": true, "Glob": true, "Grep": true} + add := func(tool string) { + if !seen[tool] { + seen[tool] = true + tools = append(tools, tool) + } + } + if len(a.WriteScope) == 0 { + add("Edit") + add("Write") + } for _, p := range a.WriteScope { - tools = append(tools, "Edit("+p+")", "Write("+p+")") + p = strings.TrimSpace(strings.ReplaceAll(p, `\`, "/")) + if p == "" { + continue + } + if p == "*" || p == "." { + add("Edit") + add("Write") + continue + } + p = strings.TrimSuffix(p, "/") + add("Edit(" + p + ")") + add("Write(" + p + ")") + // A declared directory scope covers descendants too. Adding this for + // a file scope is harmless and avoids guessing from filesystem state. + if !strings.ContainsAny(p, "*?[") { + add("Edit(" + p + "/**)") + add("Write(" + p + "/**)") + } } return tools } @@ -846,25 +1098,22 @@ func spawnCLI(ctx context.Context, spec spawnSpec) (process, error) { cmd := exec.CommandContext(ctx, spec.command, spec.args...) cmd.Dir = spec.dir cmd.Env = spec.env - in, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - out, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - errw, err := cmd.StderrPipe() + out, childOut, err := os.Pipe() if err != nil { return nil, err } + cmd.Stdout = childOut + cmd.Stderr = io.Discard if err := cmd.Start(); err != nil { + _ = out.Close() + _ = childOut.Close() return nil, err } - // The prompt comes from argv; never stream to stdin, and drain stderr - // so a chatty CLI cannot deadlock the process. - _ = in.Close() - go io.Copy(io.Discard, errw) + // The prompt comes from argv, so stdin stays closed. Close the parent's + // writer copy after Start; the child's inherited descriptor keeps the pipe + // open until exit. Unlike Cmd.StdoutPipe, this lets Wait run concurrently + // without closing unread buffered output. + _ = childOut.Close() p := &cliProcess{cmd: cmd, out: out, doneCh: make(chan struct{})} go func() { err := cmd.Wait() diff --git a/internal/claudeadapter/adapter_test.go b/internal/claudeadapter/adapter_test.go index 16f597e..8018eb5 100644 --- a/internal/claudeadapter/adapter_test.go +++ b/internal/claudeadapter/adapter_test.go @@ -40,14 +40,30 @@ func TestMain(m *testing.M) { // fakeProc simulates a claude process: it emits stream-json events written by // the test and records signals for abort assertions. type fakeProc struct { - r *io.PipeReader - w *io.PipeWriter - doneCh chan struct{} - sigCh chan os.Signal - spec spawnSpec - mu sync.Mutex - exited bool - waitErr error + r *io.PipeReader + w *io.PipeWriter + doneCh chan struct{} + sigCh chan os.Signal + spec spawnSpec + mu sync.Mutex + exited bool + waitErr error + termErr error + killErr error + killExit bool + out io.Reader +} + +type trackingReadCloser struct { + io.ReadCloser + closed chan struct{} + once sync.Once +} + +func (r *trackingReadCloser) Close() error { + err := r.ReadCloser.Close() + r.once.Do(func() { close(r.closed) }) + return err } // faultReader emits valid stream data, one synthetic read error, then more @@ -130,7 +146,12 @@ func newFakeProc() (*fakeProc, *io.PipeReader) { }, r } -func (f *fakeProc) stdout() io.Reader { return f.r } +func (f *fakeProc) stdout() io.Reader { + if f.out != nil { + return f.out + } + return f.r +} func (f *fakeProc) done() <-chan struct{} { return f.doneCh } @@ -141,6 +162,15 @@ func (f *fakeProc) wait() error { } func (f *fakeProc) signal(sig os.Signal) error { f.sigCh <- sig + if sig == syscall.SIGTERM && f.termErr != nil { + return f.termErr + } + if sig == os.Kill && f.killErr != nil { + return f.killErr + } + if sig == os.Kill && f.killExit { + f.finish(fmt.Errorf("killed")) + } return nil } @@ -181,7 +211,6 @@ func attemptFor(id, objective string) adapter.Attempt { Objective: objective, Role: "worker", Model: "claude-sonnet-5", - Cwd: "/tmp/work", WriteScope: []string{"src"}, MaxDurationSeconds: 600, } @@ -242,6 +271,37 @@ func TestDriverImplementsAdapterInterfaces(t *testing.T) { var _ adapter.PermissionSession = (*session)(nil) } +func TestAllowedToolsIncludeRecursiveWriteScope(t *testing.T) { + got := allowedTools(adapter.Attempt{WriteScope: []string{"src", "README.md", "*"}}) + want := []string{ + "Read", "Glob", "Grep", + "Edit(src)", "Write(src)", "Edit(src/**)", "Write(src/**)", + "Edit(README.md)", "Write(README.md)", "Edit(README.md/**)", "Write(README.md/**)", + "Edit", "Write", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("allowed tools = %v, want %v", got, want) + } +} + +func TestAllowedToolsEmptyScopeMeansWholeRepository(t *testing.T) { + got := allowedTools(adapter.Attempt{}) + want := []string{"Read", "Glob", "Grep", "Edit", "Write"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("allowed tools = %v, want %v", got, want) + } +} + +func TestAllowedToolsReviewerIsReadOnly(t *testing.T) { + for _, scope := range [][]string{nil, {"*"}, {"src"}} { + got := allowedTools(adapter.Attempt{Role: "reviewer", WriteScope: scope}) + want := []string{"Read", "Glob", "Grep"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("scope %v: allowed tools = %v, want %v", scope, got, want) + } + } +} + func TestSystemInitUsesClaudeStreamShapeAndRemapsSession(t *testing.T) { fp, _ := newFakeProc() drv := New(Options{DisablePermissions: true}) @@ -282,6 +342,7 @@ func TestStartCompletion(t *testing.T) { defer drv.Close() a := attemptFor("w1/1", "create src/alpha.txt") + a.Cwd = t.TempDir() sess, err := drv.Start(context.Background(), a) if err != nil { t.Fatal(err) @@ -389,8 +450,8 @@ func TestStartCompletion(t *testing.T) { if !hasWrite { t.Errorf("write scope not mapped to a Write rule: %v", fp.spec.args) } - if fp.spec.dir != "/tmp/work" { - t.Errorf("cwd = %q, want /tmp/work", fp.spec.dir) + if fp.spec.dir != a.Cwd { + t.Errorf("cwd = %q, want %q", fp.spec.dir, a.Cwd) } if !strings.Contains(fp.spec.args[1], "(role: worker)") { t.Errorf("prompt missing role header: %q", fp.spec.args[1]) @@ -400,6 +461,204 @@ func TestStartCompletion(t *testing.T) { } } +func TestCompletionCapturesWorktreeDiffsForDefaultVerification(t *testing.T) { + repo := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("before\n"), 0o644); err != nil { + t.Fatal(err) + } + git("add", "tracked.txt") + git("-c", "user.name=corral", "-c", "user.email=corral@local", "commit", "-qm", "init") + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("after\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "new file.txt"), []byte("new\n"), 0o644); err != nil { + t.Fatal(err) + } + + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + a := attemptFor("w1/1", "edit files") + a.Cwd = repo + sess, err := drv.Start(context.Background(), a) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("completions = %d, want 1", len(cs)) + } + var diffs []adapter.Diff + for _, msg := range cs[0].Messages { + if msg.Role == "user" { + diffs = append(diffs, msg.Diffs...) + } + } + if len(diffs) != 2 { + t.Fatalf("diffs = %+v, want tracked and untracked file", diffs) + } + byFile := make(map[string]adapter.Diff, len(diffs)) + for _, diff := range diffs { + byFile[diff.File] = diff + } + tracked := byFile["tracked.txt"] + if tracked.Status != "modified" || tracked.Additions != 1 || tracked.Deletions != 1 || + !strings.Contains(tracked.Patch, "+after") { + t.Errorf("tracked diff = %+v", tracked) + } + added := byFile["new file.txt"] + if added.Status != "added" || added.Additions != 1 || added.Deletions != 0 || + !strings.Contains(added.Patch, "+new") { + t.Errorf("untracked diff = %+v", added) + } + + verdict, err := verify.New(repo).Verify(context.Background(), &graph.Node{ + ID: "w1", Type: graph.NodeAgent, Objective: "edit files", + }, repo, 1, cs[0].Messages) + if err != nil { + t.Fatal(err) + } + if !verdict.Pass { + t.Fatalf("default verification rejected real file changes: %+v", verdict) + } +} + +func TestCaptureGitDiffsNonGitDirectoryIsBestEffort(t *testing.T) { + diffs, err := captureGitDiffs(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("non-Git directory returned error: %v", err) + } + if len(diffs) != 0 { + t.Fatalf("non-Git directory diffs = %+v, want none", diffs) + } +} + +func TestCaptureGitDiffsCancellationReturnsNoPartialEvidence(t *testing.T) { + repo := t.TempDir() + cmd := exec.Command("git", "init", "-q", "-b", "main") + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + diffs, err := captureGitDiffs(ctx, repo) + if !errors.Is(err, context.Canceled) { + t.Fatalf("capture error = %v, want context canceled", err) + } + if len(diffs) != 0 { + t.Fatalf("canceled capture leaked partial diffs: %+v", diffs) + } +} + +func TestCompletionFailsClosedWhenGitEvidenceCannotBeCaptured(t *testing.T) { + repo := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("before\n"), 0o644); err != nil { + t.Fatal(err) + } + git("add", "tracked.txt") + git("-c", "user.name=corral", "-c", "user.email=corral@local", "commit", "-qm", "init") + headCmd := exec.Command("git", "rev-parse", "HEAD") + headCmd.Dir = repo + head, err := headCmd.Output() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".git", "refs", "heads", "main"), []byte(strings.Repeat("0", len(strings.TrimSpace(string(head))))+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + a := attemptFor("w1/1", "edit files") + a.Cwd = repo + sess, err := drv.Start(context.Background(), a) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + + cs := drainSteps(drv, 1) + if len(cs) != 1 { + t.Fatalf("completions = %d, want 1", len(cs)) + } + if cs[0].Status != adapter.StatusError { + t.Fatalf("status = %q, want error", cs[0].Status) + } + if cs[0].Err == nil || !strings.Contains(cs[0].Err.Error(), "capture git diff evidence") { + t.Fatalf("completion error = %v, want evidence capture error", cs[0].Err) + } + for _, msg := range cs[0].Messages { + if len(msg.Diffs) != 0 { + t.Fatalf("failed capture leaked partial diffs: %+v", msg.Diffs) + } + } +} + +func TestCompletedAttemptReleasesLiveRegistryAndRejectsReuse(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + a := attemptFor("w1/1", "task") + sess, err := drv.Start(context.Background(), a) + if err != nil { + t.Fatal(err) + } + sid := sess.ID() + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sid}) + fp.finish(nil) + if got := drainSteps(drv, 1); len(got) != 1 { + t.Fatalf("completions = %d, want 1", len(got)) + } + + deadline := time.Now().Add(time.Second) + for drv.attemptByID(a.ID) != nil && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if drv.attemptByID(a.ID) != nil || drv.attemptBySession(sid) != nil { + t.Fatal("completed attempt retained in live driver registry") + } + spawned := false + drv.spawn = func(context.Context, spawnSpec) (process, error) { + spawned = true + return nil, errors.New("unexpected spawn") + } + if _, err := drv.Start(context.Background(), a); err == nil || !strings.Contains(err.Error(), "already started") { + t.Fatalf("reused attempt error = %v", err) + } + if spawned { + t.Fatal("duplicate completed attempt spawned another process") + } +} + func TestLargeStreamJSONRecordIsNotLost(t *testing.T) { fp, _ := newFakeProc() drv := New(Options{DisablePermissions: true, PollInterval: 20 * time.Millisecond}) @@ -465,6 +724,31 @@ func TestFloodedStreamPreservesEveryEventInOrder(t *testing.T) { } } +func TestCompletionBurstIsNeverDropped(t *testing.T) { + drv := New(Options{DisablePermissions: true}) + defer drv.Close() + + const count = 128 + for i := 0; i < count; i++ { + id := fmt.Sprintf("attempt-%03d", i) + at := &attempt{ + d: drv, attemptID: id, sessionID: "session-" + id, + terminal: true, exited: true, subtype: "success", + } + drv.maybeComplete(context.Background(), at) + } + + got := drv.Step(context.Background(), time.Now()) + if len(got) != count { + t.Fatalf("completion burst = %d, want %d", len(got), count) + } + for i, completion := range got { + if want := fmt.Sprintf("attempt-%03d", i); completion.AttemptID != want { + t.Fatalf("completion[%d] = %q, want %q", i, completion.AttemptID, want) + } + } +} + func TestStreamReadErrorIsReportedAfterRemainingOutputIsDrained(t *testing.T) { readErr := errors.New("synthetic stdout read failure") first, _ := json.Marshal(map[string]any{"type": "assistant", "message": map[string]any{ @@ -654,6 +938,129 @@ func TestAbort(t *testing.T) { } } +func TestAbortWaitsForProcessExitBeforeCompletion(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "long task")) + if err != nil { + t.Fatal(err) + } + abortDone := make(chan error, 1) + go func() { abortDone <- sess.Abort(context.Background()) }() + select { + case sig := <-fp.sigCh: + if sig != syscall.SIGTERM { + t.Fatalf("signal = %v, want SIGTERM", sig) + } + case <-time.After(time.Second): + t.Fatal("abort did not signal process") + } + + time.Sleep(50 * time.Millisecond) + if got := drv.Step(context.Background(), time.Now()); len(got) != 0 { + t.Fatalf("completion emitted before process exit: %+v", got) + } + fp.finish(fmt.Errorf("signal: terminated")) + select { + case err := <-abortDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("abort did not return after process exit") + } + got := drainSteps(drv, 1) + if len(got) != 1 || got[0].Status != adapter.StatusAborted { + t.Fatalf("completion = %+v, want one aborted after exit", got) + } +} + +func TestAbortFallsBackToKillWhenTerminateIsUnsupported(t *testing.T) { + fp, _ := newFakeProc() + fp.termErr = errors.New("terminate unsupported") + fp.killExit = true + drv := New(Options{DisablePermissions: true}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + if err := sess.Abort(context.Background()); err != nil { + t.Fatalf("Abort did not fall back to Kill: %v", err) + } + for _, want := range []os.Signal{syscall.SIGTERM, os.Kill} { + select { + case got := <-fp.sigCh: + if got != want { + t.Fatalf("signal = %v, want %v", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing signal %v", want) + } + } + got := drainSteps(drv, 1) + if len(got) != 1 || got[0].Status != adapter.StatusAborted { + t.Fatalf("completion = %+v, want aborted", got) + } +} + +func TestAbortSignalFailureDoesNotFalsifyLaterCompletion(t *testing.T) { + fp, _ := newFakeProc() + fp.termErr = errors.New("terminate failed") + fp.killErr = errors.New("kill failed") + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + + if err := sess.Abort(context.Background()); err == nil || + !strings.Contains(err.Error(), "terminate failed") || !strings.Contains(err.Error(), "kill failed") { + t.Fatalf("Abort error = %v, want both signal failures", err) + } + if status, err := sess.Status(context.Background()); err != nil || status != adapter.StatusRunning { + t.Fatalf("status after failed abort = %q, %v; want running", status, err) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + + got := drainSteps(drv, 1) + if len(got) != 1 || got[0].Status != adapter.StatusIdle || got[0].Err != nil { + t.Fatalf("completion after failed abort = %+v, want successful idle", got) + } +} + +func TestAbortAfterNaturalExitPreservesCompletion(t *testing.T) { + fp, _ := newFakeProc() + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + if err := sess.Abort(context.Background()); err != nil { + t.Fatal(err) + } + got := drainSteps(drv, 1) + if len(got) != 1 || got[0].Status != adapter.StatusIdle || got[0].Err != nil { + t.Fatalf("completion after natural exit = %+v, want successful idle", got) + } + select { + case signal := <-fp.sigCh: + t.Fatalf("already-exited process received signal %v", signal) + default: + } +} + // TestPermissionBroker exercises the real permission transport end to end: a // helper goroutine acting as claude's MCP permission tool connects to the // broker socket, parks a request, and waits; the scheduler-side session sees @@ -829,6 +1236,35 @@ func TestPermissionBroker(t *testing.T) { } } +func TestPermissionSocketOverridePreservesNonSocketPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker.sock") + const content = "keep me" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + drv := New(Options{PermissionSocket: path}) + defer drv.Close() + spawned := false + drv.spawn = func(context.Context, spawnSpec) (process, error) { + spawned = true + return nil, errors.New("unexpected spawn") + } + + if _, err := drv.Start(context.Background(), attemptFor("w1/1", "task")); err == nil { + t.Fatal("Start replaced a non-socket permission path") + } + if spawned { + t.Error("spawn called after unsafe socket path") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read preserved path: %v", err) + } + if string(got) != content { + t.Fatalf("preserved path = %q, want %q", got, content) + } +} + // TestPermissionHelperClaude221226InputShape crosses the full MCP helper and // broker boundary with Claude Code 2.1.226's permission-tool arguments: // {tool_name,input,tool_use_id}. It spawns the actual exported helper path so @@ -1050,6 +1486,63 @@ func TestCLIProcessWaitBlocksUntilExitResultIsCached(t *testing.T) { } } +func TestCLIProcessStdoutHelper(t *testing.T) { + if os.Getenv("CORRAL_CLAUDE_STDOUT_HELPER") != "1" { + return + } + _, _ = io.WriteString(os.Stdout, strings.Repeat("x", 4<<20)) +} + +func TestCLIProcessPreservesBufferedStdoutAfterFastExit(t *testing.T) { + proc, err := spawnCLI(context.Background(), spawnSpec{ + command: os.Args[0], + args: []string{"-test.run=^TestCLIProcessStdoutHelper$"}, + env: append(os.Environ(), "CORRAL_CLAUDE_STDOUT_HELPER=1"), + dir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(proc.stdout()) + if err != nil { + t.Fatal(err) + } + if err := proc.wait(); err != nil { + t.Fatal(err) + } + if got := bytes.Count(data, []byte("x")); got != 4<<20 { + t.Fatalf("stdout x bytes = %d, want %d", got, 4<<20) + } + if closer, ok := proc.stdout().(io.Closer); ok { + if err := closer.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestStreamScannerClosesStdoutAfterDrain(t *testing.T) { + fp, _ := newFakeProc() + tracked := &trackingReadCloser{ReadCloser: fp.r, closed: make(chan struct{})} + fp.out = tracked + drv := New(Options{DisablePermissions: true, PollInterval: 10 * time.Millisecond}) + drv.spawn = fakeSpawn(fp) + defer drv.Close() + sess, err := drv.Start(context.Background(), attemptFor("w1/1", "task")) + if err != nil { + t.Fatal(err) + } + fp.write(map[string]any{"type": "result", "subtype": "success", "session_id": sess.ID()}) + fp.finish(nil) + if got := drainSteps(drv, 1); len(got) != 1 { + t.Fatalf("completions = %d, want 1", len(got)) + } + select { + case <-tracked.closed: + case <-time.After(time.Second): + t.Fatal("stdout reader was not closed after stream drain") + } +} + // TestStartError covers the failure path: when the claude binary cannot be // spawned the driver returns an error so the scheduler fails the node. func TestStartError(t *testing.T) { diff --git a/internal/claudeadapter/permission.go b/internal/claudeadapter/permission.go index 04b557c..fc7b564 100644 --- a/internal/claudeadapter/permission.go +++ b/internal/claudeadapter/permission.go @@ -113,11 +113,26 @@ func (d *Driver) startBroker() error { } path = filepath.Join(dir, "broker.sock") } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSocket == 0 { + if dir != "" { + _ = os.Remove(dir) + } + d.brokerErr = fmt.Errorf("refusing to replace non-socket permission path %q", path) + return + } + if err := os.Remove(path); err != nil { + if dir != "" { + _ = os.Remove(dir) + } + d.brokerErr = fmt.Errorf("remove stale socket: %w", err) + return + } + } else if !os.IsNotExist(err) { if dir != "" { _ = os.Remove(dir) } - d.brokerErr = fmt.Errorf("remove stale socket: %w", err) + d.brokerErr = fmt.Errorf("inspect permission socket: %w", err) return } ln, err := net.Listen("unix", path) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index fb8b7c1..7817ad5 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -54,7 +54,7 @@ type Daemon struct { apiKey string ctx context.Context - mu sync.Mutex + mu sync.RWMutex runs map[string]*sched.RunHandle broker *broker eventHeartbeat time.Duration @@ -241,15 +241,20 @@ func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { } func (d *Daemon) runHandle(id string) (*sched.RunHandle, error) { - d.mu.Lock() - defer d.mu.Unlock() - h, ok := d.runs[id] + h, ok := d.lookupRunHandle(id) if !ok { return nil, fmt.Errorf("unknown run %s", id) } return h, nil } +func (d *Daemon) lookupRunHandle(id string) (*sched.RunHandle, bool) { + d.mu.RLock() + defer d.mu.RUnlock() + h, ok := d.runs[id] + return h, ok +} + type runSummary struct { ID string `json:"id"` Status string `json:"status"` @@ -267,7 +272,7 @@ func (d *Daemon) handleListRuns(w http.ResponseWriter, r *http.Request) { var out []runSummary for _, ru := range runs { sum := runSummary{ID: ru.ID, Status: ru.Status, States: map[string]string{}} - if h, ok := d.runs[ru.ID]; ok { + if h, ok := d.lookupRunHandle(ru.ID); ok { sum.Done = h.Done() for _, n := range ru.Graph.Nodes { if st, ok := h.State(n.ID); ok { @@ -317,7 +322,7 @@ func (d *Daemon) handleGetRun(w http.ResponseWriter, r *http.Request) { "events": events, "attempts": attempts, } - if h, ok := d.runs[id]; ok { + if h, ok := d.lookupRunHandle(id); ok { states := map[string]string{} for _, n := range ru.Graph.Nodes { if st, ok := h.State(n.ID); ok { @@ -400,7 +405,7 @@ func (d *Daemon) watchSnapshot(ctx context.Context, id string, since int64) (map states := map[string]string{} done := false - if h, ok := d.runs[id]; ok { + if h, ok := d.lookupRunHandle(id); ok { for _, n := range ru.Graph.Nodes { if st, ok := h.State(n.ID); ok { states[string(n.ID)] = string(st) @@ -500,6 +505,9 @@ func (d *Daemon) handleApprove(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusNotFound) return } + if !d.gateActionAuthorized(w, r) { + return + } d.nodeAction(w, r, func(ctx context.Context, id graph.NodeID) error { return h.ApproveNode(ctx, id) }) } @@ -509,9 +517,32 @@ func (d *Daemon) handleReject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusNotFound) return } + if !d.gateActionAuthorized(w, r) { + return + } d.nodeAction(w, r, func(ctx context.Context, id graph.NodeID) error { return h.RejectNode(ctx, id) }) } +// gateActionAuthorized enforces the run's persisted pre-authorization +// policy. Operators may always resolve human gates; orchestrators may do so +// only when the run was created with autoApproveGates enabled. +func (d *Daemon) gateActionAuthorized(w http.ResponseWriter, r *http.Request) bool { + role, _ := parseRole(r.Header.Get("X-Corral-Role")) + if role != RoleOrchestrator { + return true + } + ru, err := d.st.Run(r.Context(), r.PathValue("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return false + } + if !ru.AutoApproveGates { + http.Error(w, "orchestrator is not pre-authorized to resolve human gates", http.StatusForbidden) + return false + } + return true +} + func (d *Daemon) handleCancel(w http.ResponseWriter, r *http.Request) { h, err := d.runHandle(r.PathValue("id")) if err != nil { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 4d8874d..dd40ae0 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" "time" @@ -151,6 +153,89 @@ func TestRoleEnforcement(t *testing.T) { } } +func TestConcurrentWatchAndCreate(t *testing.T) { + a, _, _, _ := setupDaemon(t, "") + g := &graph.Graph{Nodes: []*graph.Node{gateNode("gate")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{"graph": g}) + if code != http.StatusCreated { + t.Fatalf("create seed run: %d %s", code, body) + } + var created struct{ RunID string } + if err := json.Unmarshal([]byte(body), &created); err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + errs := make(chan error, 16) + request := func(method, path string, body any) error { + var rdr io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + rdr = bytes.NewReader(data) + } + req, err := http.NewRequest(method, a.base+path, rdr) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Corral-Role", "operator") + resp, err := a.cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, respBody) + } + return nil + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + <-start + created := 0 + for attempts := 0; attempts < 1000 && created < 50; attempts++ { + if err := request(http.MethodPost, "/api/runs", map[string]any{"graph": g}); err != nil { + if strings.Contains(err.Error(), "SQLITE_BUSY") { + time.Sleep(time.Millisecond) + continue + } + errs <- err + return + } + created++ + } + if created < 50 { + errs <- fmt.Errorf("created %d concurrent runs, want 50", created) + } + }() + for range 6 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for range 200 { + if err := request(http.MethodGet, "/api/runs/"+created.RunID+"/watch?since=0&timeout=1", nil); err != nil { + errs <- err + return + } + } + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + func TestAuthRequired(t *testing.T) { a, _, _, _ := setupDaemon(t, "sekret") req, _ := http.NewRequest(http.MethodGet, a.base+"/api/health", nil) @@ -360,6 +445,53 @@ func TestPreAuthorizedGateThroughAPI(t *testing.T) { } } +func TestOrchestratorGateActionsRequirePersistedPreAuthorization(t *testing.T) { + for _, tc := range []struct { + action string + want graph.State + }{ + {action: "approve", want: graph.StateDone}, + {action: "reject", want: graph.StateBlocked}, + } { + t.Run(tc.action, func(t *testing.T) { + a, _, st, _ := setupDaemon(t, "") + g := &graph.Graph{Nodes: []*graph.Node{gateNode("gate")}} + code, body := a.do("operator", http.MethodPost, "/api/runs", map[string]any{ + "graph": g, + "autoApproveGates": false, + }) + if code != http.StatusCreated { + t.Fatalf("create: %d %s", code, body) + } + var created struct{ RunID string } + if err := json.Unmarshal([]byte(body), &created); err != nil { + t.Fatal(err) + } + a.waitState(t, "", created.RunID, "gate", graph.StateRunning, 30*time.Second) + + ru, err := st.Run(context.Background(), created.RunID) + if err != nil { + t.Fatal(err) + } + if ru.AutoApproveGates { + t.Fatal("run unexpectedly persisted with autoApproveGates=true") + } + + code, body = a.do("orchestrator", http.MethodPost, "/api/runs/"+created.RunID+"/"+tc.action, map[string]any{"nodeID": "gate"}) + if code != http.StatusForbidden { + t.Fatalf("non-pre-authorized orchestrator %s: %d %s, want 403", tc.action, code, body) + } + a.waitState(t, "", created.RunID, "gate", graph.StateRunning, 5*time.Second) + + code, body = a.do("operator", http.MethodPost, "/api/runs/"+created.RunID+"/"+tc.action, map[string]any{"nodeID": "gate"}) + if code != http.StatusOK { + t.Fatalf("operator %s: %d %s", tc.action, code, body) + } + a.waitState(t, "", created.RunID, "gate", tc.want, 30*time.Second) + }) + } +} + // TestWatchReportsGateAndDone drives the run through the watch endpoint // (long-poll JSON): it must report the gate awaiting approval, and a done // snapshot once the run completes after the operator approves. diff --git a/internal/daemon/events.go b/internal/daemon/events.go index f36387f..b58b9ce 100644 --- a/internal/daemon/events.go +++ b/internal/daemon/events.go @@ -96,6 +96,25 @@ func (d *Daemon) handleEvents(w http.ResponseWriter, r *http.Request) { hb := time.NewTicker(d.eventHeartbeatInterval()) defer hb.Stop() + flushDurable := func() (bool, error) { + events, err := d.st.EventsAfter(ctx, runID, last) + if err != nil { + return false, err + } + for _, ev := range events { + if ev.Seq <= last { + continue + } + if err := writeEventSSE(w, fl, ev); err != nil { + return false, err + } + last = ev.Seq + if terminalRunEvent(ev) { + return true, nil + } + } + return false, nil + } for { select { @@ -106,23 +125,18 @@ func (d *Daemon) handleEvents(w http.ResponseWriter, r *http.Request) { // The broker is a wake-up path, not the source of truth. Reading the // durable log here preserves order even when concurrent commits notify // out of order. - events, err := d.st.EventsAfter(ctx, runID, last) - if err != nil { + terminal, err := flushDurable() + if err != nil || terminal { return // headers are committed; reconnect replays from last id } - for _, ev := range events { - if ev.Seq <= last { - continue - } - if err := writeEventSSE(w, fl, ev); err != nil { - return - } - last = ev.Seq - if terminalRunEvent(ev) { - return - } - } case <-hb.C: + // A best-effort store or broker wakeup may be dropped when buffers + // overflow. Heartbeats also reconcile the durable cursor so a quiet + // stream never waits forever for another event. + terminal, err := flushDurable() + if err != nil || terminal { + return + } if _, err := io.WriteString(w, ": ping\n\n"); err != nil { return } diff --git a/internal/daemon/events_reconcile_test.go b/internal/daemon/events_reconcile_test.go new file mode 100644 index 0000000..a1a4528 --- /dev/null +++ b/internal/daemon/events_reconcile_test.go @@ -0,0 +1,65 @@ +package daemon + +import ( + "bufio" + "context" + "encoding/json" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "corral/internal/graph" + "corral/internal/store" +) + +func TestEventHeartbeatReconcilesDroppedWakeup(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "events.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + ctx := context.Background() + g := &graph.Graph{Nodes: []*graph.Node{{ + ID: "w", Type: graph.NodeAgent, Objective: "work", AcceptanceCriteria: []string{"done"}, + }}} + if err := st.CreateRun(ctx, "r", g, false, time.Now()); err != nil { + t.Fatal(err) + } + + // Deliberately omit the store-to-broker pump. The durable event below has + // no live wakeup, so only heartbeat cursor reconciliation can deliver it. + d := &Daemon{st: st, broker: newBroker(), eventHeartbeat: 20 * time.Millisecond} + srv := httptest.NewServer(d.Handler()) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + "/api/runs/r/events?after=1") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if _, err := st.AppendEvent(ctx, "r", "w", store.EventGraph, "", "", "", `{"version":2}`, time.Now()); err != nil { + t.Fatal(err) + } + + reader := bufio.NewReader(resp.Body) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(line, "data: ") { + var event store.Event + if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data: "))), &event); err != nil { + t.Fatal(err) + } + if event.Seq != 2 { + t.Fatalf("event seq = %s, want 2", strconv.FormatInt(event.Seq, 10)) + } + return + } + } + t.Fatal("durable event was not reconciled on heartbeat") +} diff --git a/internal/ocx/client.go b/internal/ocx/client.go index b251b11..3c3ed92 100644 --- a/internal/ocx/client.go +++ b/internal/ocx/client.go @@ -103,7 +103,14 @@ func (c *Client) promptAsync(ctx context.Context, sid, text, model, agent string "parts": []map[string]string{{"type": "text", "text": text}}, } if model != "" { - body["model"] = model + providerID, modelID, ok := strings.Cut(model, "/") + if !ok || strings.TrimSpace(providerID) == "" || strings.TrimSpace(modelID) == "" { + return fmt.Errorf("model %q must use provider/model format", model) + } + body["model"] = map[string]string{ + "providerID": providerID, + "modelID": modelID, + } } if agent != "" { body["agent"] = agent @@ -142,10 +149,21 @@ func (c *Client) Abort(ctx context.Context, sid string) error { return err } -// RespondPermission answers a pending permission request. -func (c *Client) RespondPermission(ctx context.Context, sid, permissionID, response string) error { - _, err := c.do(ctx, http.MethodPost, "/session/"+sid+"/permissions/"+permissionID, - url.Values{"directory": {c.dir}}, map[string]any{"response": response}, nil) +// PendingPermissions returns every unresolved permission request visible in +// this client's directory. Unlike the event stream, this endpoint can +// reconcile requests created during a disconnect. +func (c *Client) PendingPermissions(ctx context.Context) ([]PermissionRequest, error) { + var requests []PermissionRequest + _, err := c.do(ctx, http.MethodGet, "/permission", + url.Values{"directory": {c.dir}}, nil, &requests) + return requests, err +} + +// RespondPermission answers a pending permission request using OpenCode's +// current permission API. reply is one of "once", "always", or "reject". +func (c *Client) RespondPermission(ctx context.Context, permissionID, reply string) error { + _, err := c.do(ctx, http.MethodPost, "/permission/"+permissionID+"/reply", + url.Values{"directory": {c.dir}}, map[string]any{"reply": reply}, nil) return err } diff --git a/internal/ocx/client_test.go b/internal/ocx/client_test.go new file mode 100644 index 0000000..690e445 --- /dev/null +++ b/internal/ocx/client_test.go @@ -0,0 +1,72 @@ +package ocx + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestPromptAsyncEncodesModelReference(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := New(srv.URL, t.TempDir()) + if err := client.PromptAsync(context.Background(), "ses_1", "work", "openrouter/anthropic/claude-sonnet-4"); err != nil { + t.Fatal(err) + } + model, ok := body["model"].(map[string]any) + if !ok { + t.Fatalf("model = %#v, want object", body["model"]) + } + if model["providerID"] != "openrouter" || model["modelID"] != "anthropic/claude-sonnet-4" { + t.Fatalf("model = %#v", model) + } +} + +func TestPromptAsyncRejectsAmbiguousModel(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + defer srv.Close() + + client := New(srv.URL, t.TempDir()) + err := client.PromptAsync(context.Background(), "ses_1", "work", "claude-sonnet-4") + if err == nil || !strings.Contains(err.Error(), "provider/model") { + t.Fatalf("error = %v", err) + } + if got := requests.Load(); got != 0 { + t.Fatalf("HTTP requests = %d, want 0", got) + } +} + +func TestMessagesDecodeRoleDependentSummary(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[ + {"info":{"role":"user","summary":{"title":"change","body":"body","diffs":[{"file":"a.txt","patch":"+a","additions":1,"deletions":0,"status":"added"}]}},"parts":[]}, + {"info":{"role":"assistant","summary":true,"finish":"stop"},"parts":[]} + ]`)) + })) + defer srv.Close() + + messages, err := New(srv.URL, t.TempDir()).Messages(context.Background(), "ses_1", 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 || messages[0].Info.Summary == nil || len(messages[0].Info.Summary.Diffs) != 1 { + t.Fatalf("user summary = %#v", messages) + } + if messages[1].Info.Summary == nil || !messages[1].Info.Summary.Compacted { + t.Fatalf("assistant summary = %#v", messages[1].Info.Summary) + } +} diff --git a/internal/ocx/events.go b/internal/ocx/events.go index 708df4c..9e8e77d 100644 --- a/internal/ocx/events.go +++ b/internal/ocx/events.go @@ -18,8 +18,14 @@ type EventHandler func(Event) var streamClient = &http.Client{} func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { + return c.StreamEventsReady(ctx, nil, handler) +} + +// StreamEventsReady is StreamEvents with a callback invoked after each +// successful HTTP subscription, before any event records are read. +func (c *Client) StreamEventsReady(ctx context.Context, ready func(), handler EventHandler) error { for { - err := c.streamOnce(ctx, handler) + err := c.streamOnce(ctx, ready, handler) if ctx.Err() != nil { return ctx.Err() } @@ -34,7 +40,7 @@ func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { } } -func (c *Client) streamOnce(ctx context.Context, handler EventHandler) error { +func (c *Client) streamOnce(ctx context.Context, ready func(), handler EventHandler) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/global/event", nil) if err != nil { return err @@ -49,6 +55,9 @@ func (c *Client) streamOnce(ctx context.Context, handler EventHandler) error { if resp.StatusCode != http.StatusOK { return fmt.Errorf("event stream: %s", resp.Status) } + if ready != nil { + ready() + } br := bufio.NewReader(resp.Body) for { line, err := br.ReadString('\n') diff --git a/internal/ocx/types.go b/internal/ocx/types.go index a121084..9c6efa1 100644 --- a/internal/ocx/types.go +++ b/internal/ocx/types.go @@ -21,6 +21,14 @@ type SessionStatus struct { Next int `json:"next"` } +// PermissionRequest is an unresolved OpenCode permission request. The +// global permission endpoint is durable, so adapters can reconcile requests +// that were emitted while the SSE stream was disconnected. +type PermissionRequest struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` +} + type FileDiff struct { File string `json:"file"` Patch string `json:"patch"` @@ -40,6 +48,35 @@ type TokenCount struct { } `json:"cache"` } +// MessageSummary is role-dependent on the OpenCode wire: user messages use +// an object containing diffs, while compacted assistant messages use a bool. +// Keeping both shapes decodable prevents one compacted assistant message from +// making the entire transcript unreadable. +type MessageSummary struct { + Title string `json:"title"` + Body string `json:"body"` + Diffs []FileDiff `json:"diffs"` + Compacted bool `json:"-"` +} + +func (s *MessageSummary) UnmarshalJSON(data []byte) error { + var compacted bool + if err := json.Unmarshal(data, &compacted); err == nil { + s.Compacted = compacted + s.Title = "" + s.Body = "" + s.Diffs = nil + return nil + } + type summary MessageSummary + var decoded summary + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *s = MessageSummary(decoded) + return nil +} + type MessageInfo struct { ID string `json:"id"` Role string `json:"role"` @@ -52,12 +89,8 @@ type MessageInfo struct { Cost float64 `json:"cost"` Tokens TokenCount Error *json.RawMessage `json:"error"` - Summary *struct { - Title string `json:"title"` - Body string `json:"body"` - Diffs []FileDiff `json:"diffs"` - } `json:"summary"` - Time struct { + Summary *MessageSummary `json:"summary"` + Time struct { Created int64 `json:"created"` Completed *int64 `json:"completed"` } `json:"time"` diff --git a/internal/ocxadapter/adapter.go b/internal/ocxadapter/adapter.go index a82ff55..b9fafa8 100644 --- a/internal/ocxadapter/adapter.go +++ b/internal/ocxadapter/adapter.go @@ -23,7 +23,11 @@ type Options struct { // PollInterval is the fallback status-poll period when events are // missed or the stream is down. PollInterval time.Duration - // Model overrides the default model for sessions ("" = server default). + // StreamReadyTimeout bounds how long the first Start waits for an SSE + // subscription before proceeding with REST reconciliation (default 1s). + StreamReadyTimeout time.Duration + // Model is the driver default when Attempt.Model is empty + // ("" leaves model selection to the server). Model string } @@ -34,6 +38,26 @@ func (o Options) poll() time.Duration { return o.PollInterval } +func (o Options) streamReadyTimeout() time.Duration { + if o.StreamReadyTimeout <= 0 { + return time.Second + } + return o.StreamReadyTimeout +} + +func (o Options) permissionPoll() time.Duration { + poll := o.poll() + if poll < 100*time.Millisecond { + return 100 * time.Millisecond + } + if poll > time.Second { + return time.Second + } + return poll +} + +const permissionRequestTimeout = 500 * time.Millisecond + // Driver implements adapter.Driver and adapter.Stepper for OpenCode. type Driver struct { oc *ocx.Client @@ -42,13 +66,17 @@ type Driver struct { mu sync.Mutex attempts map[string]*attempt // attemptID -> rec bySession map[string]*attempt // sessionID -> rec + seen map[string]struct{} // all successfully reserved attempt IDs clients map[string]*ocx.Client // cwd -> client (worktrees) - completions chan adapter.Completion + completions []adapter.Completion - streamOnce sync.Once - streamCtx context.Context - streamCancel context.CancelFunc - closed bool + streamOnce sync.Once + streamReady chan struct{} + readyOnce sync.Once + streamCtx context.Context + streamCancel context.CancelFunc + permissionWake chan struct{} + closed bool } type attempt struct { @@ -67,12 +95,14 @@ type attempt struct { func New(oc *ocx.Client, opts Options) *Driver { return &Driver{ - oc: oc, - opts: opts, - attempts: map[string]*attempt{}, - bySession: map[string]*attempt{}, - clients: map[string]*ocx.Client{}, - completions: make(chan adapter.Completion, 64), + oc: oc, + opts: opts, + attempts: map[string]*attempt{}, + bySession: map[string]*attempt{}, + seen: map[string]struct{}{}, + clients: map[string]*ocx.Client{}, + streamReady: make(chan struct{}), + permissionWake: make(chan struct{}, 1), } } @@ -83,10 +113,29 @@ func (d *Driver) Close() { return } d.closed = true + ats := make([]*attempt, 0, len(d.attempts)) + for _, at := range d.attempts { + ats = append(ats, at) + } d.mu.Unlock() if d.streamCancel != nil { d.streamCancel() } + d.readyOnce.Do(func() { close(d.streamReady) }) + for _, at := range ats { + at.cancel() + } + var wg sync.WaitGroup + for _, at := range ats { + wg.Add(1) + go func(at *attempt) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = at.oc.Abort(ctx, at.sessionID) + }(at) + } + wg.Wait() } // clientFor returns the client bound to a directory (the attempt's @@ -108,17 +157,33 @@ func (d *Driver) clientFor(cwd string) *ocx.Client { // Start creates an OpenCode session, sends the objective, and starts a // watcher that completes the attempt via events + polling fallback. func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, error) { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil, fmt.Errorf("start OpenCode: driver is closed") + } + if _, exists := d.seen[a.ID]; exists { + d.mu.Unlock() + return nil, fmt.Errorf("start OpenCode: attempt %q already started", a.ID) + } + d.seen[a.ID] = struct{}{} + d.mu.Unlock() + reserved := true + defer func() { + if !reserved { + return + } + d.mu.Lock() + delete(d.seen, a.ID) + d.mu.Unlock() + }() + client := d.clientFor(a.Cwd) title := "corral/" + a.NodeID sess, err := client.CreateSession(ctx, title) if err != nil { return nil, fmt.Errorf("create session: %w", err) } - prompt := promptFor(a) - if err := client.PromptAsync(ctx, sess.ID, prompt, d.opts.Model); err != nil { - return nil, fmt.Errorf("prompt: %w", err) - } - atCtx, atCancel := context.WithCancel(context.Background()) at := &attempt{ d: d, @@ -130,11 +195,84 @@ func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, cancel: atCancel, } d.mu.Lock() + if d.closed { + d.mu.Unlock() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Abort(cleanupCtx, sess.ID) + cancel() + return nil, fmt.Errorf("start OpenCode: driver is closed") + } d.attempts[a.ID] = at d.bySession[sess.ID] = at + d.startStream(context.Background()) + d.mu.Unlock() + readyTimer := time.NewTimer(d.opts.streamReadyTimeout()) + select { + case <-d.streamReady: + case <-readyTimer.C: + // REST transcript/status polling completes attempts, while the durable + // permission poll recovers prompts missed before a later SSE reconnect. + // Open the shared gate so an unavailable stream delays only the first + // Start instead of serially delaying every attempt. + d.readyOnce.Do(func() { close(d.streamReady) }) + case <-ctx.Done(): + if !readyTimer.Stop() { + select { + case <-readyTimer.C: + default: + } + } + atCancel() + at.cleanup() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Abort(cleanupCtx, sess.ID) + cancel() + return nil, fmt.Errorf("start OpenCode event stream: %w", ctx.Err()) + } + if !readyTimer.Stop() { + select { + case <-readyTimer.C: + default: + } + } + d.mu.Lock() + closed := d.closed d.mu.Unlock() + if closed { + atCancel() + at.cleanup() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Abort(cleanupCtx, sess.ID) + cancel() + return nil, fmt.Errorf("start OpenCode: driver is closed") + } - d.startStream(atCtx) + prompt := promptFor(a) + model := a.Model + if model == "" { + model = d.opts.Model + } + if err := client.PromptAsync(ctx, sess.ID, prompt, model); err != nil { + atCancel() + at.cleanup() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Abort(cleanupCtx, sess.ID) + cancel() + return nil, fmt.Errorf("prompt: %w", err) + } + d.wakePermissionReconcile() + d.mu.Lock() + closed = d.closed + d.mu.Unlock() + if closed { + atCancel() + at.cleanup() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Abort(cleanupCtx, sess.ID) + cancel() + return nil, fmt.Errorf("start OpenCode: driver closed during prompt") + } + reserved = false go at.watch(atCtx) return &session{oc: client, at: at}, nil } @@ -153,15 +291,11 @@ func promptFor(a adapter.Attempt) string { // Step drains completed attempts (non-blocking). func (d *Driver) Step(_ context.Context, _ time.Time) []adapter.Completion { - var out []adapter.Completion - for { - select { - case c := <-d.completions: - out = append(out, c) - default: - return out - } - } + d.mu.Lock() + defer d.mu.Unlock() + out := d.completions + d.completions = nil + return out } // startStream opens the shared SSE stream exactly once and dispatches @@ -171,8 +305,11 @@ func (d *Driver) startStream(ctx context.Context) { sc, cancel := context.WithCancel(ctx) d.streamCtx = sc d.streamCancel = cancel + go d.reconcilePermissions(sc) go func() { - err := d.oc.StreamEvents(sc, func(ev ocx.Event) { + err := d.oc.StreamEventsReady(sc, func() { + d.readyOnce.Do(func() { close(d.streamReady) }) + }, func(ev ocx.Event) { var p struct { SessionID string `json:"sessionID"` } @@ -180,13 +317,22 @@ func (d *Driver) startStream(ctx context.Context) { return } switch ev.Type { - case "session.idle", "session.error", "permission.updated": + case "session.idle", "session.error", + "permission.asked", "permission.v2.asked", + "permission.replied", "permission.v2.replied", + "permission.updated": // pre-1.18 compatibility d.mu.Lock() at := d.bySession[p.SessionID] d.mu.Unlock() if at == nil { return } + if strings.HasPrefix(ev.Type, "permission.") { + // Record permission events inline. PendingPermission also polls the + // durable endpoint, covering reconnect gaps and dropped events. + at.handleEvent(ev) + return + } select { case at.events <- ev: default: // dropped; the poll fallback covers it @@ -200,15 +346,81 @@ func (d *Driver) startStream(ctx context.Context) { }) } +// wakePermissionReconcile asks the shared background poller to query pending +// permissions without putting provider I/O on the scheduler's hot path. +func (d *Driver) wakePermissionReconcile() { + select { + case d.permissionWake <- struct{}{}: + default: + } +} + +// reconcilePermissions continuously polls OpenCode's durable permission list. +// It is shared by all attempts and clients, so PendingPermission remains a +// local, non-blocking scheduler query even while the SSE stream reconnects. +func (d *Driver) reconcilePermissions(ctx context.Context) { + d.reconcilePermissionsOnce(ctx) + ticker := time.NewTicker(d.opts.permissionPoll()) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-d.permissionWake: + } + d.reconcilePermissionsOnce(ctx) + } +} + +func (d *Driver) reconcilePermissionsOnce(ctx context.Context) { + d.mu.Lock() + groups := make(map[*ocx.Client][]*attempt) + for _, at := range d.attempts { + groups[at.oc] = append(groups[at.oc], at) + } + d.mu.Unlock() + + var wg sync.WaitGroup + for client, attempts := range groups { + client, attempts := client, attempts + wg.Add(1) + go func() { + defer wg.Done() + pollCtx, cancel := context.WithTimeout(ctx, permissionRequestTimeout) + defer cancel() + requests, err := client.PendingPermissions(pollCtx) + if err != nil { + return // keep last-known state; retry on the next shared poll + } + pending := make(map[string]string) + for _, request := range requests { + if request.SessionID == "" || request.ID == "" { + continue + } + if current := pending[request.SessionID]; current == "" || request.ID < current { + pending[request.SessionID] = request.ID + } + } + for _, at := range attempts { + at.mu.Lock() + at.permission = pending[at.sessionID] + at.mu.Unlock() + } + }() + } + wg.Wait() +} + // watch completes the attempt when the session reaches a terminal state, // using the event stream as primary signal and polling as fallback. func (at *attempt) watch(ctx context.Context) { + defer at.cleanup() poll := time.NewTicker(at.d.opts.poll()) defer poll.Stop() for { select { case <-ctx.Done(): - at.cleanup() return case <-poll.C: at.d.maybeComplete(ctx, at) @@ -219,10 +431,10 @@ func (at *attempt) watch(ctx context.Context) { } func (at *attempt) handleEvent(ev ocx.Event) { - if ev.Type == "permission.updated" { + switch ev.Type { + case "permission.asked", "permission.v2.asked", "permission.updated": var p struct { - ID string `json:"id"` - Type string `json:"type"` + ID string `json:"id"` } if err := ev.UnmarshalProps(&p); err == nil && p.ID != "" { at.mu.Lock() @@ -230,6 +442,18 @@ func (at *attempt) handleEvent(ev ocx.Event) { at.mu.Unlock() return } + case "permission.replied", "permission.v2.replied": + var p struct { + RequestID string `json:"requestID"` + } + if err := ev.UnmarshalProps(&p); err == nil && p.RequestID != "" { + at.mu.Lock() + if at.permission == p.RequestID { + at.permission = "" + } + at.mu.Unlock() + return + } } at.d.maybeComplete(context.Background(), at) } @@ -273,11 +497,31 @@ func (d *Driver) maybeComplete(ctx context.Context, at *attempt) { Status: status, Messages: toAdapterMessages(msgs), } - select { - case d.completions <- c: - default: - log.Printf("ocxadapter: completion channel full; dropping %s", at.attemptID) + if status == adapter.StatusError { + c.Err = terminalError(msgs) } + d.completions = append(d.completions, c) + at.cancel() +} + +func terminalError(msgs []ocx.Message) error { + for i := len(msgs) - 1; i >= 0; i-- { + message := msgs[i] + if message.Info.Role != "assistant" { + continue + } + if message.Info.Error != nil { + if name := errorName(message.Info.Error); name != "" { + return fmt.Errorf("OpenCode assistant error: %s", name) + } + return fmt.Errorf("OpenCode assistant error") + } + if message.Info.Finish != nil && *message.Info.Finish != "" { + return fmt.Errorf("OpenCode finish reason: %s", *message.Info.Finish) + } + break + } + return fmt.Errorf("OpenCode attempt failed") } // terminalStatus decides whether the transcript shows a terminal attempt @@ -301,7 +545,14 @@ func terminalStatus(msgs []ocx.Message, aborted bool) (bool, adapter.Status) { return true, adapter.StatusError } if m.Info.Finish != nil { - return true, adapter.StatusIdle + switch *m.Info.Finish { + case "", "tool-calls": + return false, "" // still streaming or entering a tool step + case "stop": + return true, adapter.StatusIdle + default: + return true, adapter.StatusError + } } return false, "" // still streaming } @@ -374,8 +625,11 @@ func (s *session) Send(ctx context.Context, text string) error { return s.oc.PromptAsync(ctx, s.at.sessionID, text, "") } func (s *session) Abort(ctx context.Context) error { + if err := s.oc.Abort(ctx, s.at.sessionID); err != nil { + return err + } s.at.aborted.Store(true) - return s.oc.Abort(ctx, s.at.sessionID) + return nil } func (s *session) Status(ctx context.Context) (adapter.Status, error) { statuses, err := s.oc.SessionStatus(ctx) @@ -401,21 +655,31 @@ func (s *session) Messages(ctx context.Context) ([]adapter.Message, error) { func (s *session) PendingPermission(_ context.Context) (string, bool, error) { s.at.mu.Lock() defer s.at.mu.Unlock() - if s.at.permission != "" { - return s.at.permission, true, nil - } - return "", false, nil + return s.at.permission, s.at.permission != "", nil } func (s *session) RespondPermission(ctx context.Context, id string, allow bool) error { - response := "deny" + pending, ok, err := s.PendingPermission(ctx) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("ocxadapter: no pending permission request") + } + if pending != id { + return fmt.Errorf("ocxadapter: permission %q is not pending (want %q)", id, pending) + } + reply := "reject" if allow { - response = "allow" + reply = "once" + } + if err := s.oc.RespondPermission(ctx, id, reply); err != nil { + return err } s.at.mu.Lock() if s.at.permission == id { s.at.permission = "" // resolved; resume continues automatically } s.at.mu.Unlock() - return s.oc.RespondPermission(ctx, s.at.sessionID, id, response) + return nil } diff --git a/internal/ocxadapter/adapter_test.go b/internal/ocxadapter/adapter_test.go index c27d67b..0701748 100644 --- a/internal/ocxadapter/adapter_test.go +++ b/internal/ocxadapter/adapter_test.go @@ -2,6 +2,10 @@ package ocxadapter_test import ( "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -22,6 +26,548 @@ import ( "corral/internal/verify" ) +func TestStartRejectsDuplicateAndClosedDriver(t *testing.T) { + var mu sync.Mutex + created := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + mu.Lock() + created++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_unit"}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_unit/prompt_async": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_unit/abort": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{}) + t.Cleanup(drv.Close) + a := adapter.Attempt{ID: "run/n/1", NodeID: "n", Objective: "work"} + if _, err := drv.Start(context.Background(), a); err != nil { + t.Fatal(err) + } + if _, err := drv.Start(context.Background(), a); err == nil || !strings.Contains(err.Error(), "already started") { + t.Fatalf("duplicate Start error = %v", err) + } + mu.Lock() + gotCreated := created + mu.Unlock() + if gotCreated != 1 { + t.Fatalf("created sessions = %d, want 1", gotCreated) + } + drv.Close() + if _, err := drv.Start(context.Background(), adapter.Attempt{ID: "run/n/2", NodeID: "n", Objective: "work"}); err == nil || !strings.Contains(err.Error(), "closed") { + t.Fatalf("Start after Close error = %v", err) + } +} + +func TestStartUsesAttemptModelOverrideOnPromptWire(t *testing.T) { + tests := []struct { + name string + driverModel string + attemptModel string + wantProvider string + wantModel string + }{ + { + name: "attempt override", + driverModel: "anthropic/claude-sonnet-4", + attemptModel: "openai/gpt-5", + wantProvider: "openai", + wantModel: "gpt-5", + }, + { + name: "driver fallback", + driverModel: "anthropic/claude-sonnet-4", + wantProvider: "anthropic", + wantModel: "claude-sonnet-4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got struct { + Model struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` + } `json:"model"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_model"}) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_model/prompt_async": + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode prompt: %v", err) + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + _ = json.NewEncoder(w).Encode([]any{}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_model/abort": + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{Model: tt.driverModel}) + t.Cleanup(drv.Close) + _, err := drv.Start(context.Background(), adapter.Attempt{ + ID: "run/n/1", NodeID: "n", Objective: "work", Model: tt.attemptModel, + }) + if err != nil { + t.Fatal(err) + } + if got.Model.ProviderID != tt.wantProvider || got.Model.ModelID != tt.wantModel { + t.Fatalf("prompt model = %#v, want providerID=%q modelID=%q", got.Model, tt.wantProvider, tt.wantModel) + } + }) + } +} + +func TestStartSubscribesBeforePromptCanEmitPermission(t *testing.T) { + streamReady := make(chan struct{}) + permissionSent := make(chan struct{}) + var sendOnce sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_fast"}) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + f, _ := w.(http.Flusher) + f.Flush() + close(streamReady) + <-permissionSent + payload, _ := json.Marshal(map[string]any{ + "type": "permission.asked", + "properties": map[string]any{"sessionID": "ses_fast", "id": "perm-fast"}, + }) + frame, _ := json.Marshal(map[string]any{"payload": json.RawMessage(payload)}) + _, _ = fmt.Fprintf(w, "data: %s\n\n", frame) + f.Flush() + <-r.Context().Done() + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_fast/prompt_async": + select { + case <-streamReady: + default: + t.Error("prompt arrived before event subscription was ready") + } + sendOnce.Do(func() { close(permissionSent) }) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + select { + case <-permissionSent: + _ = json.NewEncoder(w).Encode([]map[string]string{{"id": "perm-fast", "sessionID": "ses_fast"}}) + default: + _ = json.NewEncoder(w).Encode([]any{}) + } + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_fast/abort": + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{PollInterval: time.Hour}) + t.Cleanup(drv.Close) + sess, err := drv.Start(context.Background(), adapter.Attempt{ID: "run/n/1", NodeID: "n", Objective: "work"}) + if err != nil { + t.Fatal(err) + } + ps := sess.(adapter.PermissionSession) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if id, ok, _ := ps.PendingPermission(context.Background()); ok && id == "perm-fast" { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("permission emitted during prompt was lost") +} + +func TestStartFallsBackToRESTWhenEventStreamIsUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_rest"}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_rest/prompt_async": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/session/ses_rest/message": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "info": map[string]any{ + "id": "msg-rest", "role": "assistant", + "sessionID": "ses_rest", "finish": "stop", + }, + "parts": []any{}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + _ = json.NewEncoder(w).Encode([]any{}) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + http.Error(w, "events unavailable", http.StatusServiceUnavailable) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_rest/abort": + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{ + PollInterval: 5 * time.Millisecond, + StreamReadyTimeout: 20 * time.Millisecond, + }) + t.Cleanup(drv.Close) + if _, err := drv.Start(context.Background(), adapter.Attempt{ + ID: "run/n/1", NodeID: "n", Objective: "work", + }); err != nil { + t.Fatalf("Start with unavailable SSE: %v", err) + } + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if got := drv.Step(context.Background(), time.Now()); len(got) > 0 { + if got[0].Status != adapter.StatusIdle { + t.Fatalf("REST completion status = %q, want idle", got[0].Status) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("REST polling did not complete attempt while SSE was unavailable") +} + +func TestPermissionReconcilesAfterEventGapWithoutBlockingCallers(t *testing.T) { + streamReady := make(chan struct{}) + permissionPolled := make(chan struct{}) + var pollOnce sync.Once + var mu sync.Mutex + pending := false + blockPermission := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_gap"}) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-streamReady: + default: + close(streamReady) + } + // End this subscription without a permission event. The durable + // permission poll must recover the missed request. + return + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_gap/prompt_async": + <-streamReady + mu.Lock() + pending = true + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + mu.Lock() + isPending := pending + block := blockPermission + mu.Unlock() + if block { + <-r.Context().Done() + return + } + if isPending { + pollOnce.Do(func() { close(permissionPolled) }) + _ = json.NewEncoder(w).Encode([]map[string]string{{ + "id": "perm-gap", "sessionID": "ses_gap", + }}) + return + } + _ = json.NewEncoder(w).Encode([]any{}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_gap/abort": + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{ + PollInterval: 5 * time.Millisecond, + StreamReadyTimeout: 100 * time.Millisecond, + }) + t.Cleanup(drv.Close) + sess, err := drv.Start(context.Background(), adapter.Attempt{ + ID: "run/n/1", NodeID: "n", Objective: "work", + }) + if err != nil { + t.Fatal(err) + } + ps := sess.(adapter.PermissionSession) + + select { + case <-permissionPolled: + case <-time.After(time.Second): + t.Fatal("background reconciliation never polled durable permissions") + } + deadline := time.Now().Add(time.Second) + for { + if id, ok, err := ps.PendingPermission(context.Background()); err != nil { + t.Fatal(err) + } else if ok && id == "perm-gap" { + break + } + if time.Now().After(deadline) { + t.Fatal("missed permission was not reconciled into local state") + } + time.Sleep(time.Millisecond) + } + + mu.Lock() + blockPermission = true + mu.Unlock() + returned := make(chan struct{}) + go func() { + _, _, _ = ps.PendingPermission(context.Background()) + close(returned) + }() + select { + case <-returned: + case <-time.After(50 * time.Millisecond): + t.Fatal("PendingPermission performed blocking network I/O") + } +} + +func TestAbortFailureDoesNotReportLocalAbort(t *testing.T) { + var abortCalls int + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_abort"}) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_abort/prompt_async": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_abort/abort": + mu.Lock() + abortCalls++ + call := abortCalls + mu.Unlock() + if call == 1 { + http.Error(w, "abort failed", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{}) + t.Cleanup(drv.Close) + sess, err := drv.Start(context.Background(), adapter.Attempt{ID: "run/n/1", NodeID: "n", Objective: "work"}) + if err != nil { + t.Fatal(err) + } + if err := sess.Abort(context.Background()); err == nil { + t.Fatal("provider abort failure was hidden") + } + if status, err := sess.Status(context.Background()); err == nil && status == adapter.StatusAborted { + t.Fatal("failed provider abort was reported as locally aborted") + } +} + +func TestPermissionResponseValidatesIDAndRetainsFailedDecision(t *testing.T) { + var mu sync.Mutex + permissionCalls := 0 + failDecision := true + pending := true + gotReply := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_perm"}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_perm/prompt_async": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_perm/abort": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + mu.Lock() + isPending := pending + mu.Unlock() + if isPending { + _ = json.NewEncoder(w).Encode([]map[string]string{{"id": "perm-1", "sessionID": "ses_perm"}}) + } else { + _ = json.NewEncoder(w).Encode([]any{}) + } + case r.Method == http.MethodPost && r.URL.Path == "/permission/perm-1/reply": + var body struct { + Reply string `json:"reply"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode permission reply: %v", err) + } + mu.Lock() + permissionCalls++ + fail := failDecision + gotReply = body.Reply + if !fail { + pending = false + } + mu.Unlock() + if fail { + http.Error(w, "try again", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + f, _ := w.(http.Flusher) + f.Flush() + // Deliberately emit no permission event. The durable /permission + // endpoint must reconcile a request missed during an SSE gap. + <-r.Context().Done() + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{PollInterval: time.Hour}) + t.Cleanup(drv.Close) + sess, err := drv.Start(context.Background(), adapter.Attempt{ID: "run/n/1", NodeID: "n", Objective: "work"}) + if err != nil { + t.Fatal(err) + } + ps := sess.(adapter.PermissionSession) + deadline := time.Now().Add(time.Second) + for { + if id, ok, _ := ps.PendingPermission(context.Background()); ok && id == "perm-1" { + break + } + if time.Now().After(deadline) { + t.Fatal("permission event was not tracked") + } + time.Sleep(time.Millisecond) + } + if err := ps.RespondPermission(context.Background(), "wrong", true); err == nil { + t.Fatal("wrong permission ID was accepted") + } + mu.Lock() + gotCalls := permissionCalls + mu.Unlock() + if gotCalls != 0 { + t.Fatalf("wrong permission reached provider %d times", gotCalls) + } + if err := ps.RespondPermission(context.Background(), "perm-1", true); err == nil { + t.Fatal("provider failure was hidden") + } + if id, ok, _ := ps.PendingPermission(context.Background()); !ok || id != "perm-1" { + t.Fatalf("failed response cleared pending permission: %q, %v", id, ok) + } + mu.Lock() + failDecision = false + mu.Unlock() + if err := ps.RespondPermission(context.Background(), "perm-1", true); err != nil { + t.Fatal(err) + } + mu.Lock() + reply := gotReply + mu.Unlock() + if reply != "once" { + t.Fatalf("allow reply = %q, want once", reply) + } + if id, ok, _ := ps.PendingPermission(context.Background()); ok { + t.Fatalf("successful response left permission pending: %q", id) + } +} + +func TestIntermediateFinishDoesNotComplete(t *testing.T) { + var mu sync.Mutex + finish := "tool-calls" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + _ = json.NewEncoder(w).Encode(map[string]string{"id": "ses_finish"}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_finish/prompt_async": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/session/ses_finish/message": + mu.Lock() + current := finish + mu.Unlock() + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "info": map[string]any{"id": "msg-1", "role": "assistant", "sessionID": "ses_finish", "finish": current}, + "parts": []any{}, + }}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_finish/abort": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/global/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + drv := ocxadapter.New(ocx.New(srv.URL, t.TempDir()), ocxadapter.Options{PollInterval: 5 * time.Millisecond}) + t.Cleanup(drv.Close) + if _, err := drv.Start(context.Background(), adapter.Attempt{ID: "run/n/1", NodeID: "n", Objective: "work"}); err != nil { + t.Fatal(err) + } + time.Sleep(40 * time.Millisecond) + if got := drv.Step(context.Background(), time.Now()); len(got) != 0 { + t.Fatalf("intermediate finish emitted completion: %+v", got) + } + mu.Lock() + finish = "stop" + mu.Unlock() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if got := drv.Step(context.Background(), time.Now()); len(got) > 0 { + if got[0].Status != adapter.StatusIdle { + t.Fatalf("terminal status = %q, want idle", got[0].Status) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("finish=stop did not emit completion") +} + const ( w1Prompt = "Create a file named alpha.txt containing exactly one line: CORRAL-OC1. Do not run any other commands." w2Prompt = "Append one line to beta.txt every second, 30 lines total, numbered 1 to 30, using bash. Keep going until the loop finishes. Do not stop early." diff --git a/internal/ocxadapter/terminal_test.go b/internal/ocxadapter/terminal_test.go new file mode 100644 index 0000000..17534fd --- /dev/null +++ b/internal/ocxadapter/terminal_test.go @@ -0,0 +1,48 @@ +package ocxadapter + +import ( + "encoding/json" + "strings" + "testing" + + "corral/internal/adapter" + "corral/internal/ocx" +) + +func TestTerminalStatusClassifiesFinishReasons(t *testing.T) { + tests := []struct { + name string + finish string + terminal bool + status adapter.Status + }{ + {name: "stop", finish: "stop", terminal: true, status: adapter.StatusIdle}, + {name: "tool calls", finish: "tool-calls", terminal: false}, + {name: "length", finish: "length", terminal: true, status: adapter.StatusError}, + {name: "content filter", finish: "content-filter", terminal: true, status: adapter.StatusError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + finish := test.finish + messages := []ocx.Message{{Info: ocx.MessageInfo{ + Role: "assistant", Finish: &finish, + }}} + terminal, status := terminalStatus(messages, false) + if terminal != test.terminal || status != test.status { + t.Fatalf("terminalStatus(%q) = (%v, %q), want (%v, %q)", + test.finish, terminal, status, test.terminal, test.status) + } + }) + } +} + +func TestTerminalErrorDescribesProviderFailure(t *testing.T) { + finish := "length" + if got := terminalError([]ocx.Message{{Info: ocx.MessageInfo{Role: "assistant", Finish: &finish}}}); got == nil || !strings.Contains(got.Error(), "length") { + t.Fatalf("finish error = %v", got) + } + raw := json.RawMessage(`{"name":"ProviderAuthError"}`) + if got := terminalError([]ocx.Message{{Info: ocx.MessageInfo{Role: "assistant", Error: &raw}}}); got == nil || !strings.Contains(got.Error(), "ProviderAuthError") { + t.Fatalf("assistant error = %v", got) + } +} diff --git a/internal/ocxreviewer/reviewer.go b/internal/ocxreviewer/reviewer.go index d581e0a..929a453 100644 --- a/internal/ocxreviewer/reviewer.go +++ b/internal/ocxreviewer/reviewer.go @@ -2,19 +2,17 @@ // OpenCode sessions (Task 4's reviewer gate). A reviewer session receives // the completed attempt's evidence — objective, prior feedback, transcript, // the recorded diff artifact and any check results — and must conclude with -// an explicit verdict: APPROVED or NOT_APPROVED plus a note. The note +// an explicit verdict: APPROVED or CHANGES_REQUESTED plus a note. The note // becomes the gate feedback when the verdict is not approved, so the worker -// knows exactly what to fix. Sessions are read-only: the reviewer may -// inspect the worktree and run tests, but never modify files. +// knows exactly what to fix. Sessions use the named reviewer agent with all +// tools denied and evaluate only the supplied evidence. package ocxreviewer import ( "context" "encoding/json" "fmt" - "regexp" "strings" - "sync" "time" "corral/internal/adapter" @@ -48,64 +46,34 @@ func (o Options) poll() time.Duration { return o.PollInterval } -// reviewTools keeps reviewer sessions read-only. The reviewer evaluates the -// recorded diff, transcript, and check results included in its prompt; shell -// access is disabled because it can mutate the attempt worktree. -var reviewTools = map[string]bool{ - "bash": false, - "edit": false, - "write": false, - "apply_patch": false, - "websearch": false, - "webfetch": false, - "task": false, - "todowrite": false, - "question": false, - "skill": false, - "lsp": false, +const reviewerAgent = "corral-reviewer" + +// denyTools returns OpenCode's prompt-level wildcard deny. OpenCode converts +// this entry into a wildcard permission rule, covering built-in, plugin, MCP, +// and future tools without relying on the incomplete experimental ID list. +func denyTools() map[string]bool { + return map[string]bool{"*": false} } // Driver implements verify.Reviewer for OpenCode sessions. type Driver struct { oc *ocx.Client opts Options - - mu sync.Mutex - clients map[string]*ocx.Client // cwd -> client (worktrees) } func New(oc *ocx.Client, opts Options) *Driver { - return &Driver{ - oc: oc, - opts: opts, - clients: map[string]*ocx.Client{}, - } -} - -// clientFor returns the client bound to a directory (the attempt's -// worktree when isolated), creating it on first use. -func (d *Driver) clientFor(cwd string) *ocx.Client { - if cwd == "" { - return d.oc - } - d.mu.Lock() - defer d.mu.Unlock() - if c, ok := d.clients[cwd]; ok { - return c - } - c := ocx.New(d.oc.Base(), cwd) - d.clients[cwd] = c - return c + return &Driver{oc: oc, opts: opts} } // Review runs a reviewer session for the attempt's evidence, waits for the // session to reach idle, and parses the verdict from the transcript. func (d *Driver) Review(ctx context.Context, req verify.ReviewRequest) (bool, string, error) { - cwd := req.Worktree - if cwd == "" { - cwd = req.Attempt.Cwd - } - client := d.clientFor(cwd) + // Reviewer agent configuration belongs to the daemon's main OpenCode + // project and is not guaranteed to exist in generated Git worktrees. The + // reviewer is evidence-only with all tools denied, so it does not need a + // session bound to the attempt worktree. + client := d.oc + reviewTools := denyTools() title := "corral/review/" + req.Attempt.NodeID sess, err := client.CreateSession(ctx, title) @@ -113,7 +81,8 @@ func (d *Driver) Review(ctx context.Context, req verify.ReviewRequest) (bool, st return false, "", fmt.Errorf("review session: %w", err) } prompt := promptFor(req) - if err := client.PromptAsyncWithTools(ctx, sess.ID, prompt, d.opts.Model, reviewTools); err != nil { + if err := client.PromptAsyncAgentWithTools(ctx, sess.ID, prompt, d.opts.Model, reviewerAgent, reviewTools); err != nil { + abortReview(client, sess.ID) return false, "", fmt.Errorf("review prompt: %w", err) } @@ -122,6 +91,7 @@ func (d *Driver) Review(ctx context.Context, req verify.ReviewRequest) (bool, st for { select { case <-ctx.Done(): + abortReview(client, sess.ID) return false, "", ctx.Err() default: } @@ -140,18 +110,25 @@ func (d *Driver) Review(ctx context.Context, req verify.ReviewRequest) (bool, st select { case <-time.After(d.opts.poll()): case <-ctx.Done(): + abortReview(client, sess.ID) return false, "", ctx.Err() } } // Timed out: kill the session so it stops generating, and fail fast. - _ = client.Abort(ctx, sess.ID) + abortReview(client, sess.ID) if lastPollErr != nil { return false, "", fmt.Errorf("review timed out after %s (last poll: %v)", d.opts.timeout(), lastPollErr) } return false, "", fmt.Errorf("review timed out after %s (no terminal response)", d.opts.timeout()) } +func abortReview(client *ocx.Client, sessionID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = client.Abort(ctx, sessionID) +} + // terminal reports whether the newest assistant message ended the session, // returning the error name when it terminated with a session error. func terminal(msgs []ocx.Message) (bool, string) { @@ -164,21 +141,30 @@ func terminal(msgs []ocx.Message) (bool, string) { return true, errorName(m.Info.Error) } if m.Info.Finish != nil { - return true, "" + switch *m.Info.Finish { + case "", "tool-calls": + return false, "" + case "stop": + return true, "" + default: + return true, "finish reason: " + *m.Info.Finish + } } return false, "" } return false, "" } -// verdict scans the transcript's assistant text (newest message first) for -// an explicit APPROVED / NOT_APPROVED verdict and its note. +// verdict parses only the newest assistant response. Text parts are joined in +// their original order because OpenCode may split one continuous response +// across parts; older assistant messages can never supply a stale verdict. func verdict(msgs []ocx.Message) (bool, string, error) { for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if m.Info.Role != "assistant" { continue } + var text strings.Builder for _, part := range m.Parts { var p struct { Type string `json:"type"` @@ -187,34 +173,43 @@ func verdict(msgs []ocx.Message) (bool, string, error) { if json.Unmarshal(part, &p) != nil || p.Type != "text" { continue } - if approved, note, ok := parseVerdict(p.Text); ok { - return approved, note, nil - } + text.WriteString(p.Text) + } + if approved, note, ok := parseVerdict(text.String()); ok { + return approved, note, nil } + return false, "", fmt.Errorf("reviewer produced no explicit verdict") } return false, "", fmt.Errorf("reviewer produced no explicit verdict") } -var verdictRe = regexp.MustCompile(`(?i)NOT[_ ]?APPROVED|APPROVED`) - -// parseVerdict extracts an APPROVED / NOT_APPROVED verdict and the note -// that follows it from the model's reply. The note is the "Note: ..." text -// after the verdict keyword, capped so it stays focused. +// parseVerdict accepts exactly two lines: a case-sensitive verdict followed +// by a non-empty "Note: ..." line. Rejecting surrounding prose, legacy +// spellings, and extra lines prevents a verdict word inside commentary from +// being mistaken for the reviewer's decision. func parseVerdict(text string) (approved bool, note string, ok bool) { - loc := verdictRe.FindStringIndex(text) - if loc == nil { + text = strings.TrimSuffix(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + lines := strings.Split(text, "\n") + if len(lines) != 2 { return false, "", false } - kw := strings.ToUpper(text[loc[0]:loc[1]]) - approved = kw != "NOT_APPROVED" && kw != "NOT APPROVED" - rest := text[loc[1]:] - if idx := strings.Index(strings.ToLower(rest), "note:"); idx >= 0 { - note = strings.TrimSpace(rest[idx+len("note:"):]) - if end := strings.Index(note, "\n\n"); end >= 0 { - note = strings.TrimSpace(note[:end]) - } - note = truncate(note, 2000) + switch lines[0] { + case "APPROVED": + approved = true + case "CHANGES_REQUESTED": + approved = false + default: + return false, "", false + } + const notePrefix = "Note: " + if !strings.HasPrefix(lines[1], notePrefix) { + return false, "", false + } + note = strings.TrimSpace(strings.TrimPrefix(lines[1], notePrefix)) + if note == "" { + return false, "", false } + note = truncate(note, 2000) return approved, note, true } @@ -257,7 +252,7 @@ Note: or -NOT_APPROVED +CHANGES_REQUESTED Note: `) return b.String() diff --git a/internal/ocxreviewer/reviewer_test.go b/internal/ocxreviewer/reviewer_test.go index 8eefde8..c906df3 100644 --- a/internal/ocxreviewer/reviewer_test.go +++ b/internal/ocxreviewer/reviewer_test.go @@ -14,6 +14,7 @@ import ( "time" "corral/internal/adapter" + "corral/internal/assets" "corral/internal/livetest" "corral/internal/ocx" "corral/internal/spike" @@ -25,11 +26,14 @@ import ( // script surface. The last transcript repeats, so a reviewer that keeps // polling settles on it deterministically. type fakeLLM struct { - mu sync.Mutex - steps [][]ocx.Message - step int - prompts []string - sessions int + mu sync.Mutex + steps [][]ocx.Message + step int + prompts []string + promptAgents []string + promptTools []map[string]bool + directories []string + sessions int } func newFakeLLM(steps ...[]ocx.Message) *fakeLLM { @@ -40,17 +44,22 @@ func (f *fakeLLM) serve() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() + f.directories = append(f.directories, r.URL.Query().Get("directory")) switch { case r.Method == http.MethodPost && r.URL.Path == "/session": f.sessions++ _ = json.NewEncoder(w).Encode(ocx.Session{ID: "ses_1", Directory: "proj", Title: "review"}) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt_async"): var body struct { + Agent string `json:"agent"` + Tools map[string]bool `json:"tools"` Parts []struct { Text string `json:"text"` } `json:"parts"` } _ = json.NewDecoder(r.Body).Decode(&body) + f.promptAgents = append(f.promptAgents, body.Agent) + f.promptTools = append(f.promptTools, body.Tools) for _, p := range body.Parts { f.prompts = append(f.prompts, p.Text) } @@ -73,11 +82,19 @@ func llmBusy() []ocx.Message { } func llmText(text string) ocx.Message { + return llmTextParts(text) +} + +func llmTextParts(texts ...string) ocx.Message { finish := "stop" - part, _ := json.Marshal(map[string]string{"type": "text", "text": text}) + parts := make([]json.RawMessage, 0, len(texts)) + for _, text := range texts { + part, _ := json.Marshal(map[string]string{"type": "text", "text": text}) + parts = append(parts, part) + } return ocx.Message{ Info: ocx.MessageInfo{Role: "assistant", Finish: &finish}, - Parts: []json.RawMessage{part}, + Parts: parts, } } @@ -117,8 +134,10 @@ func TestReviewerApproved(t *testing.T) { srv := llm.serve() defer srv.Close() - drv := New(ocx.New(srv.URL, t.TempDir()), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) - approved, note, err := drv.Review(context.Background(), reviewReq(t.TempDir())) + mainDir := t.TempDir() + worktree := t.TempDir() + drv := New(ocx.New(srv.URL, mainDir), Options{PollInterval: time.Millisecond, Timeout: 5 * time.Second}) + approved, note, err := drv.Review(context.Background(), reviewReq(worktree)) if err != nil { t.Fatal(err) } @@ -132,6 +151,17 @@ func TestReviewerApproved(t *testing.T) { if len(llm.prompts) != 1 { t.Fatalf("review prompts = %d, want 1", len(llm.prompts)) } + if got := llm.promptAgents[0]; got != "corral-reviewer" { + t.Errorf("review agent = %q, want %q", got, "corral-reviewer") + } + if got := llm.promptTools[0]; len(got) != 1 || got["*"] { + t.Errorf("review tools = %#v, want wildcard deny", got) + } + for _, directory := range llm.directories { + if directory != mainDir { + t.Errorf("review request directory = %q, want main project %q", directory, mainDir) + } + } for _, want := range []string{"OBJECTIVE", "create manifest.json", "PRIOR FEEDBACK", "DIFF ARTIFACT", "manifest.json", "CHECK RESULTS", "exit=0"} { if !strings.Contains(llm.prompts[0], want) { t.Errorf("prompt missing %q", want) @@ -140,7 +170,7 @@ func TestReviewerApproved(t *testing.T) { } func TestReviewerNotApproved(t *testing.T) { - llm := newFakeLLM([]ocx.Message{llmText("NOT_APPROVED\nNote: the manifest is still missing the required count field.")}) + llm := newFakeLLM([]ocx.Message{llmText("CHANGES_REQUESTED\nNote: the manifest is still missing the required count field.")}) srv := llm.serve() defer srv.Close() @@ -170,6 +200,17 @@ func TestReviewerSessionError(t *testing.T) { } } +func TestReviewerTerminalFinishReasons(t *testing.T) { + toolCalls := "tool-calls" + if terminal, errName := terminal([]ocx.Message{{Info: ocx.MessageInfo{Role: "assistant", Finish: &toolCalls}}}); terminal || errName != "" { + t.Fatalf("tool-calls = terminal %v error %q, want still running", terminal, errName) + } + length := "length" + if terminal, errName := terminal([]ocx.Message{{Info: ocx.MessageInfo{Role: "assistant", Finish: &length}}}); !terminal || !strings.Contains(errName, "length") { + t.Fatalf("length = terminal %v error %q, want provider error", terminal, errName) + } +} + func TestReviewerNoVerdict(t *testing.T) { llm := newFakeLLM([]ocx.Message{llmText("The changes look fine to me; ship it.")}) srv := llm.serve() @@ -204,11 +245,17 @@ func TestParseVerdict(t *testing.T) { ok bool }{ {"APPROVED\nNote: good work", true, "good work", true}, - {"NOT_APPROVED\nNote: missing tests", false, "missing tests", true}, - {"NOT APPROVED.\nNote: wrong order.", false, "wrong order.", true}, - {"We approve.\nAPPROVED\nNote: verified by hand.", true, "verified by hand.", true}, + {"APPROVED\nNote: good work\n", true, "good work", true}, + {"APPROVED\r\nNote: good work\r\n", true, "good work", true}, + {"CHANGES_REQUESTED\nNote: missing tests", false, "missing tests", true}, {"no verdict anywhere", false, "", false}, - {"NOT_APPROVED without a note", false, "", true}, + {"NOT_APPROVED\nNote: legacy verdict", false, "", false}, + {"NOT APPROVED\nNote: legacy verdict", false, "", false}, + {"We approve.\nAPPROVED\nNote: embedded verdict", false, "", false}, + {"APPROVED\nNote:", false, "", false}, + {"APPROVED\nNote: valid\nextra", false, "", false}, + {"approved\nNote: wrong case", false, "", false}, + {"APPROVED Note: one line", false, "", false}, } for _, c := range cases { approved, note, ok := parseVerdict(c.text) @@ -219,6 +266,25 @@ func TestParseVerdict(t *testing.T) { } } +func TestVerdictRejectsTextOutsideExactResponse(t *testing.T) { + t.Run("extra text part", func(t *testing.T) { + msgs := []ocx.Message{llmTextParts("Here is my decision:\n", "APPROVED\nNote: verified")} + if _, _, err := verdict(msgs); err == nil { + t.Fatal("verdict accepted response with extra text part") + } + }) + + t.Run("stale prior verdict", func(t *testing.T) { + msgs := []ocx.Message{ + llmText("APPROVED\nNote: stale"), + llmText("I cannot decide."), + } + if _, _, err := verdict(msgs); err == nil { + t.Fatal("verdict accepted stale prior assistant response") + } + }) +} + func TestPromptForIncludesEvidence(t *testing.T) { req := reviewReq("/tmp/worktree") p := promptFor(req) @@ -233,7 +299,7 @@ func TestPromptForIncludesEvidence(t *testing.T) { "exit=0", "TRANSCRIPT", "VERDICT", - "NOT_APPROVED", + "CHANGES_REQUESTED", } { if !strings.Contains(p, want) { t.Errorf("prompt missing %q", want) @@ -242,13 +308,9 @@ func TestPromptForIncludesEvidence(t *testing.T) { } func TestReviewToolsAreReadOnly(t *testing.T) { - for _, name := range []string{"bash", "edit", "write", "apply_patch"} { - enabled, explicit := reviewTools[name] - if !explicit { - t.Errorf("%s tool has no explicit deny rule", name) - } else if enabled { - t.Errorf("%s tool enabled in read-only reviewer", name) - } + reviewTools := denyTools() + if len(reviewTools) != 1 || reviewTools["*"] { + t.Fatalf("review tools = %#v, want wildcard deny", reviewTools) } } @@ -275,6 +337,9 @@ func TestOpenCodeReviewerLive(t *testing.T) { if err := os.WriteFile(filepath.Join(proj, "manifest.json"), []byte(`{"name":"x"}`), 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(proj, "opencode.json"), []byte(assets.OpenCodeConfigJSON), 0o644); err != nil { + t.Fatal(err) + } srv, err := spike.StartServer(ctx, proj, 0, os.Stderr) if err != nil { diff --git a/internal/sched/hardening_test.go b/internal/sched/hardening_test.go index f75f14a..9c828f5 100644 --- a/internal/sched/hardening_test.go +++ b/internal/sched/hardening_test.go @@ -2,15 +2,108 @@ package sched_test import ( "context" + "fmt" + "sync" "testing" "time" "corral/internal/adapter" "corral/internal/graph" "corral/internal/sched" + "corral/internal/store" "corral/internal/verify" ) +type flakyAbortDriver struct { + mu sync.Mutex + session *flakyAbortSession + emitted bool + attempt adapter.Attempt +} + +type flakyAbortSession struct { + driver *flakyAbortDriver + abortCalls int + aborted bool +} + +func (d *flakyAbortDriver) Start(_ context.Context, attempt adapter.Attempt) (adapter.Session, error) { + d.mu.Lock() + defer d.mu.Unlock() + d.attempt = attempt + d.session = &flakyAbortSession{driver: d} + return d.session, nil +} + +func (d *flakyAbortDriver) Step(context.Context, time.Time) []adapter.Completion { + d.mu.Lock() + defer d.mu.Unlock() + if d.session == nil || !d.session.aborted || d.emitted { + return nil + } + d.emitted = true + return []adapter.Completion{{AttemptID: d.attempt.ID, SessionID: d.session.ID(), Status: adapter.StatusAborted}} +} + +func (s *flakyAbortSession) ID() string { return "ses-flaky-abort" } +func (s *flakyAbortSession) ServerID() string { return "fake" } +func (s *flakyAbortSession) Send(context.Context, string) error { return nil } +func (s *flakyAbortSession) Abort(context.Context) error { + s.driver.mu.Lock() + defer s.driver.mu.Unlock() + s.abortCalls++ + if s.abortCalls == 1 { + return fmt.Errorf("transient abort failure") + } + s.aborted = true + return nil +} +func (s *flakyAbortSession) Status(context.Context) (adapter.Status, error) { + s.driver.mu.Lock() + defer s.driver.mu.Unlock() + if s.aborted { + return adapter.StatusAborted, nil + } + return adapter.StatusRunning, nil +} +func (s *flakyAbortSession) Messages(context.Context) ([]adapter.Message, error) { return nil, nil } +func (s *flakyAbortSession) Close(context.Context) error { return nil } + +func TestBudgetAbortFailureRetriesUntilProviderStops(t *testing.T) { + st := newStore(t) + clk := fakeClock() + drv := &flakyAbortDriver{} + n := agent("w1") + n.Budget.MaxDuration = tick + s := sched.New(st, drv, sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{Concurrency: 1}) + h, err := s.Create(context.Background(), "run-abort-retry", &graph.Graph{Nodes: []*graph.Node{n}}) + if err != nil { + t.Fatal(err) + } + if err := h.Step(context.Background()); err != nil { + t.Fatal(err) + } + clk.Advance(2 * tick) + if err := h.Step(context.Background()); err != nil { + t.Fatal(err) + } + if state, _ := h.State("w1"); state != graph.StateRunning { + t.Fatalf("state after failed abort = %s, want running for retry", state) + } + if err := h.Step(context.Background()); err != nil { + t.Fatal(err) + } + if state, _ := h.State("w1"); state != graph.StateFailed { + t.Fatalf("state after retried abort = %s, want failed", state) + } + drv.mu.Lock() + calls := drv.session.abortCalls + drv.mu.Unlock() + if calls != 2 { + t.Fatalf("abort calls = %d, want 2", calls) + } +} + func TestPermissionWaitPausesAttemptTimeBudget(t *testing.T) { st := newStore(t) clk := fakeClock() @@ -68,6 +161,94 @@ func TestPermissionWaitPausesAttemptTimeBudget(t *testing.T) { } } +func TestSharedDriverRoutesCompletionsToOwningRun(t *testing.T) { + st := newStore(t) + clk := fakeClock() + drv := sched.NewFakeDriver(clk, map[string][]sched.Script{ + "a": {{Delay: tick, Messages: []adapter.Message{{Role: "assistant", Finish: "stop", Text: "a"}}}}, + "b": {{Delay: tick, Messages: []adapter.Message{{Role: "assistant", Finish: "stop", Text: "b"}}}}, + }) + engine := &sched.EngineVerifier{Eng: verify.New(t.TempDir())} + s := newSched(t, st, drv, engine, clk, sched.Options{Concurrency: 1}) + n1, n2 := agent("a"), agent("b") + n1.Verification = &graph.Verification{Kind: "command", Command: []string{"true"}} + n2.Verification = &graph.Verification{Kind: "command", Command: []string{"true"}} + h1, err := s.Create(context.Background(), "run-one", &graph.Graph{Nodes: []*graph.Node{n1}}) + if err != nil { + t.Fatal(err) + } + h2, err := s.Create(context.Background(), "run-two", &graph.Graph{Nodes: []*graph.Node{n2}}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := h1.Step(ctx); err != nil { + t.Fatal(err) + } + if err := h2.Step(ctx); err != nil { + t.Fatal(err) + } + clk.Advance(tick) + + // One Stepper call drains both completions. Each must be retained for its + // owning handle instead of making whichever run stepped first fail. + if err := h1.Step(ctx); err != nil { + t.Fatalf("run one consumed another run's completion: %v", err) + } + if state, _ := h1.State("a"); state != graph.StateDone { + t.Fatalf("run one node = %s, want done", state) + } + if state, _ := h2.State("b"); state != graph.StateRunning { + t.Fatalf("run two node changed before its handle stepped: %s", state) + } + if err := h2.Step(ctx); err != nil { + t.Fatalf("run two lost its routed completion: %v", err) + } + if state, _ := h2.State("b"); state != graph.StateDone { + t.Fatalf("run two node = %s, want done", state) + } +} + +func TestCompletionBurstBeyondLegacyBuffer(t *testing.T) { + st := newStore(t) + clk := fakeClock() + const count = 40 + nodes := make([]*graph.Node, 0, count) + scripts := make(map[string][]sched.Script, count) + for i := 0; i < count; i++ { + id := graph.NodeID(fmt.Sprintf("n%02d", i)) + n := agent(id) + n.Verification = &graph.Verification{Kind: "command", Command: []string{"true"}} + nodes = append(nodes, n) + scripts[string(id)] = []sched.Script{{Delay: tick}} + } + drv := sched.NewFakeDriver(clk, scripts) + s := newSched(t, st, drv, &sched.EngineVerifier{Eng: verify.New(t.TempDir())}, clk, sched.Options{Concurrency: count}) + h, err := s.Create(context.Background(), "run-burst", &graph.Graph{Nodes: nodes}) + if err != nil { + t.Fatal(err) + } + if err := h.Step(context.Background()); err != nil { + t.Fatal(err) + } + clk.Advance(tick) + done := make(chan error, 1) + go func() { done <- h.Step(context.Background()) }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("completion burst blocked while routing more than 32 results") + } + for _, n := range nodes { + if state, _ := h.State(n.ID); state != graph.StateDone { + t.Fatalf("%s = %s, want done", n.ID, state) + } + } +} + // TestPermissionRequestBlocksExplicitly drives the permission flow: the // node moves to an explicit blocked state while the session waits, then // resumes automatically after the operator answers, and completes. @@ -232,4 +413,235 @@ func TestRunBudgetBlocksNewWork(t *testing.T) { } } +func TestLoadRestoresRunBudgetAndRetryCannotBypassIt(t *testing.T) { + st := newStore(t) + clk := fakeClock() + ctx := context.Background() + a, z := agent("a"), agent("z") + g := &graph.Graph{Nodes: []*graph.Node{a, z}} + seed := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + if _, err := seed.Create(ctx, "run-budget-reload", g); err != nil { + t.Fatal(err) + } + persistTerminalAttempt(t, st, clk.Now(), "run-budget-reload", "a", graph.StateDone, 1.25, 100) + + drv := sched.NewFakeDriver(clk, scriptsFor("z")) + s := newSched(t, st, drv, sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{ + Concurrency: 1, RunMaxTokens: 50, + }) + h, err := s.Load(ctx, "run-budget-reload") + if err != nil { + t.Fatal(err) + } + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if got, _ := h.State("z"); got != graph.StateBlocked { + t.Fatalf("z = %s after reload, want blocked by restored run budget", got) + } + if err := h.RetryNode(ctx, "z"); err != nil { + t.Fatal(err) + } + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if got, _ := h.State("z"); got != graph.StateBlocked { + t.Fatalf("z = %s after retry, want run budget to block it again", got) + } + if attempts, _ := st.CountAttempts(ctx, "run-budget-reload", "z"); attempts != 0 { + t.Fatalf("z started despite restored run budget: %d attempts", attempts) + } +} + +func TestLoadRestoresBreakerAndOperatorRetryResetsHistory(t *testing.T) { + st := newStore(t) + clk := fakeClock() + ctx := context.Background() + a, z := agent("a"), agent("z") + g := &graph.Graph{Nodes: []*graph.Node{a, z}} + seed := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + if _, err := seed.Create(ctx, "run-breaker-reload", g); err != nil { + t.Fatal(err) + } + persistTerminalAttempt(t, st, clk.Now(), "run-breaker-reload", "a", graph.StateFailed, 0, 0) + + opts := sched.Options{Concurrency: 1, BreakerMaxFailures: 1, BreakerWindow: time.Hour} + drv := sched.NewFakeDriver(clk, scriptsFor("z")) + s := newSched(t, st, drv, sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, opts) + h, err := s.Load(ctx, "run-breaker-reload") + if err != nil { + t.Fatal(err) + } + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if got, _ := h.State("z"); got != graph.StateBlocked { + t.Fatalf("z = %s after reload, want blocked by restored breaker", got) + } + if err := h.RetryNode(ctx, "z"); err != nil { + t.Fatal(err) + } + + // Retry reset is durable: a fresh handle must not reconstruct failures + // from before the operator override. + drv2 := sched.NewFakeDriver(clk, scriptsFor("z")) + s2 := newSched(t, st, drv2, sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, opts) + h2, err := s2.Load(ctx, "run-breaker-reload") + if err != nil { + t.Fatal(err) + } + if err := h2.Step(ctx); err != nil { + t.Fatal(err) + } + if got, _ := h2.State("z"); got != graph.StateRunning { + t.Fatalf("z = %s after durable breaker reset, want running", got) + } +} + +func TestLoadIgnoresHistoricalRetryForTerminalNode(t *testing.T) { + st := newStore(t) + clk := fakeClock() + ctx := context.Background() + n := agent("w1") + seed := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + if _, err := seed.Create(ctx, "run-terminal-retry", &graph.Graph{Nodes: []*graph.Node{n}}); err != nil { + t.Fatal(err) + } + now := clk.Now() + for _, edge := range [][2]graph.State{ + {graph.StatePending, graph.StateReady}, + {graph.StateReady, graph.StateLeased}, + {graph.StateLeased, graph.StateRunning}, + {graph.StateRunning, graph.StateVerifying}, + {graph.StateVerifying, graph.StateRetryWait}, + } { + if _, err := st.AppendTransition(ctx, "run-terminal-retry", "w1", edge[0], edge[1], "", now); err != nil { + t.Fatal(err) + } + } + oldReady := now.Add(-time.Hour).UnixMilli() + if _, err := st.AppendEvent(ctx, "run-terminal-retry", "w1", store.EventRetry, "", "", "", fmt.Sprintf(`{"readyAt":%d}`, oldReady), now); err != nil { + t.Fatal(err) + } + for _, edge := range [][2]graph.State{ + {graph.StateRetryWait, graph.StateReady}, + {graph.StateReady, graph.StateLeased}, + {graph.StateLeased, graph.StateRunning}, + {graph.StateRunning, graph.StateVerifying}, + {graph.StateVerifying, graph.StateDone}, + } { + if _, err := st.AppendTransition(ctx, "run-terminal-retry", "w1", edge[0], edge[1], "", now); err != nil { + t.Fatal(err) + } + } + + s := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + h, err := s.Load(ctx, "run-terminal-retry") + if err != nil { + t.Fatal(err) + } + if err := h.Step(ctx); err != nil { + t.Fatalf("historical retry corrupted terminal replay: %v", err) + } + if state, _ := h.State("w1"); state != graph.StateDone { + t.Fatalf("w1 = %s, want done", state) + } +} + +func TestLoadUsesLatestRetryDeadline(t *testing.T) { + st := newStore(t) + clk := fakeClock() + ctx := context.Background() + n := agent("w1") + seed := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + if _, err := seed.Create(ctx, "run-latest-retry", &graph.Graph{Nodes: []*graph.Node{n}}); err != nil { + t.Fatal(err) + } + now := clk.Now() + for _, edge := range [][2]graph.State{ + {graph.StatePending, graph.StateReady}, + {graph.StateReady, graph.StateLeased}, + {graph.StateLeased, graph.StateRunning}, + {graph.StateRunning, graph.StateVerifying}, + {graph.StateVerifying, graph.StateRetryWait}, + } { + if _, err := st.AppendTransition(ctx, "run-latest-retry", "w1", edge[0], edge[1], "", now); err != nil { + t.Fatal(err) + } + } + for _, readyAt := range []int64{now.Add(-time.Hour).UnixMilli(), now.Add(time.Hour).UnixMilli()} { + if _, err := st.AppendEvent(ctx, "run-latest-retry", "w1", store.EventRetry, "", "", "", fmt.Sprintf(`{"readyAt":%d}`, readyAt), now); err != nil { + t.Fatal(err) + } + } + s := newSched(t, st, sched.NewFakeDriver(clk, nil), sched.NewFakeVerifier(nil, sched.Verdict{Pass: true}), clk, sched.Options{}) + h, err := s.Load(ctx, "run-latest-retry") + if err != nil { + t.Fatal(err) + } + if err := h.Step(ctx); err != nil { + t.Fatal(err) + } + if state, _ := h.State("w1"); state != graph.StateRetryWait { + t.Fatalf("w1 = %s, want retry_wait until latest deadline", state) + } +} + +func TestRetryDependentWithFailedDependencyReblocksCleanly(t *testing.T) { + st := newStore(t) + clk := fakeClock() + a := agent("a") + a.RetryPolicy.MaxRetries = 0 + b := agent("b", "a") + drv := sched.NewFakeDriver(clk, scriptsFor("a")) + ver := sched.NewFakeVerifier(map[string][]sched.Verdict{ + "a": {{Pass: false, Feedback: "fail"}}, + }, sched.Verdict{Pass: true}) + s := newSched(t, st, drv, ver, clk, sched.Options{Concurrency: 1}) + h, err := s.Create(context.Background(), "run-dependent-retry", &graph.Graph{Nodes: []*graph.Node{a, b}}) + if err != nil { + t.Fatal(err) + } + drive(t, h, clk, 20) + if got, _ := h.State("b"); got != graph.StateBlocked { + t.Fatalf("b = %s, want blocked", got) + } + if err := h.RetryNode(context.Background(), "b"); err != nil { + t.Fatal(err) + } + if err := h.Step(context.Background()); err != nil { + t.Fatalf("reblock retried dependent: %v", err) + } + if got, _ := h.State("b"); got != graph.StateBlocked { + t.Fatalf("b = %s after retry, want blocked while dependency failed", got) + } +} + +func persistTerminalAttempt(t *testing.T, st *store.Store, now time.Time, runID string, nodeID graph.NodeID, terminal graph.State, cost float64, tokens int) { + t.Helper() + ctx := context.Background() + states := []graph.State{graph.StateReady, graph.StateLeased, graph.StateRunning, graph.StateVerifying, terminal} + from := graph.StatePending + for _, to := range states { + if _, err := st.AppendEvent(ctx, runID, string(nodeID), store.EventTransition, from, to, "", "", now); err != nil { + t.Fatal(err) + } + from = to + } + if err := st.SetNodeState(ctx, runID, string(nodeID), terminal, now); err != nil { + t.Fatal(err) + } + started, finished := now.Add(-tick).UnixMilli(), now.UnixMilli() + status := "done" + if terminal == graph.StateFailed { + status = "failed" + } + if err := st.RecordAttempt(ctx, store.Attempt{ + ID: runID + "/" + string(nodeID) + "/1", RunID: runID, NodeID: string(nodeID), No: 1, + Status: status, StartedAt: &started, FinishedAt: &finished, Cost: cost, Tokens: tokens, + }); err != nil { + t.Fatal(err) + } +} + var _ = sched.Verdict{} diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 64f7497..7747514 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -23,7 +23,6 @@ import ( ) const ( - resultsBuffer = 32 runStatusDone = "completed" runStatusWaiting = "waiting" ) @@ -86,6 +85,11 @@ type Scheduler struct { ver Verifier clk clock.Clock opts Options + + completionMu sync.Mutex + owners map[string]*RunHandle + pending map[*RunHandle][]Result + orphans map[string][]Result } func New(st *store.Store, drv adapter.Driver, ver Verifier, clk clock.Clock, opts Options) *Scheduler { @@ -95,7 +99,11 @@ func New(st *store.Store, drv adapter.Driver, ver Verifier, clk clock.Clock, opt if opts.LeaseTTL <= 0 { opts.LeaseTTL = 30 * time.Second } - return &Scheduler{store: st, drv: drv, ver: ver, clk: clk, opts: opts} + return &Scheduler{ + store: st, drv: drv, ver: ver, clk: clk, opts: opts, + owners: map[string]*RunHandle{}, pending: map[*RunHandle][]Result{}, + orphans: map[string][]Result{}, + } } type sessionRec struct { @@ -129,7 +137,7 @@ type RunHandle struct { runCost float64 runTokens int breaker bool - results chan Result + results []Result holder string done bool stepCount int64 @@ -237,26 +245,49 @@ func (s *Scheduler) Load(ctx context.Context, runID string) (*RunHandle, error) retryAt: map[graph.NodeID]time.Time{}, age: map[graph.NodeID]int{}, feedback: map[graph.NodeID]string{}, - results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, } + for _, n := range r.Graph.Nodes { + cost, tokens, err := s.store.NodeCost(ctx, runID, string(n.ID)) + if err != nil { + return nil, err + } + h.runCost += cost + h.runTokens += tokens + } for _, ev := range events { - if ev.Type == store.EventRetry { - var p struct { - ReadyAt int64 `json:"readyAt"` - } - if json.Unmarshal(ev.Payload, &p) == nil && p.ReadyAt > 0 { - h.retryAt[graph.NodeID(ev.NodeID)] = time.UnixMilli(p.ReadyAt) - } + if operatorRetryEvent(ev) { + // Operator retry is the durable circuit-breaker reset marker. + h.failures = nil + } + if ev.Type == store.EventTransition && ev.To == graph.StateFailed { + h.failures = append(h.failures, time.UnixMilli(ev.CreatedAt)) + } + } + for _, n := range r.Graph.Nodes { + state, _ := tr.State(n.ID) + if state != graph.StateRetryWait { + continue + } + if readyAt, ok := retryReadyAt(events, n.ID); ok { + h.retryAt[n.ID] = readyAt } } return h, nil } -// retryReadyAt finds the scheduled retry time from retry events. +func operatorRetryEvent(ev store.Event) bool { + var payload struct { + Reason string `json:"reason"` + } + return json.Unmarshal(ev.Payload, &payload) == nil && payload.Reason == "operator retry" +} + +// retryReadyAt finds the latest scheduled retry time from retry events. func retryReadyAt(events []store.Event, nodeID graph.NodeID) (time.Time, bool) { - for _, ev := range events { + for i := len(events) - 1; i >= 0; i-- { + ev := events[i] if graph.NodeID(ev.NodeID) != nodeID || ev.Type != store.EventRetry { continue } @@ -285,12 +316,66 @@ func (s *Scheduler) newHandle(ctx context.Context, runID string, g *graph.Graph, retryAt: map[graph.NodeID]time.Time{}, age: map[graph.NodeID]int{}, feedback: map[graph.NodeID]string{}, - results: make(chan Result, resultsBuffer), holder: fmt.Sprintf("corral-%d", now.UnixNano()), started: now, }, nil } +func completionResult(c adapter.Completion) Result { + return Result{ + AttemptID: c.AttemptID, SessionID: c.SessionID, Status: c.Status, + Messages: c.Messages, Err: c.Err, Budget: c.Budget, + } +} + +// registerAttempt binds a provider completion to its owning run. A different +// run may drain the shared driver between Start returning and this call, so +// completions observed in that window are retained as orphans and claimed now. +func (s *Scheduler) registerAttempt(h *RunHandle, attemptID string) { + s.completionMu.Lock() + defer s.completionMu.Unlock() + s.owners[attemptID] = h + if queued := s.orphans[attemptID]; len(queued) > 0 { + s.pending[h] = append(s.pending[h], queued...) + delete(s.orphans, attemptID) + } +} + +func (s *Scheduler) unregisterAttempt(h *RunHandle, attemptID string) { + s.completionMu.Lock() + defer s.completionMu.Unlock() + if s.owners[attemptID] == h { + delete(s.owners, attemptID) + } +} + +// collectCompletions drains the shared provider once and routes every result +// to its owning RunHandle. Calls from concurrent run loops are serialized. +func (s *Scheduler) collectCompletions(ctx context.Context, now time.Time) { + stepper, ok := s.drv.(adapter.Stepper) + if !ok { + return + } + s.completionMu.Lock() + defer s.completionMu.Unlock() + for _, completion := range stepper.Step(ctx, now) { + result := completionResult(completion) + if owner := s.owners[completion.AttemptID]; owner != nil { + s.pending[owner] = append(s.pending[owner], result) + } else { + s.orphans[completion.AttemptID] = append(s.orphans[completion.AttemptID], result) + } + } +} + +func (s *Scheduler) takeCompletions(h *RunHandle) []Result { + s.completionMu.Lock() + defer s.completionMu.Unlock() + results := s.pending[h] + delete(s.pending, h) + return results +} + // Step advances the run by one deterministic step. func (h *RunHandle) Step(ctx context.Context) error { h.mu.Lock() @@ -313,9 +398,15 @@ func (h *RunHandle) Step(ctx context.Context) error { // 2. Budget deadlines: abort attempts that exceeded their time budget. for _, rec := range h.sessions { if rec.budgeted && !rec.deadline.IsZero() && now.After(rec.deadline) { - _ = rec.sess.Abort(ctx) - rec.budgeted = false // abort already requested; completion arrives via results rec.abortIsBudget = true + if err := rec.sess.Abort(ctx); err != nil { + // Keep the deadline armed. Provider abort failures must be retried; + // otherwise one transient error lets an over-budget attempt run + // indefinitely. A natural completion racing this failure is still + // rejected below because abortIsBudget remains set. + continue + } + rec.budgeted = false // abort accepted; completion arrives via results } } @@ -373,34 +464,19 @@ func (h *RunHandle) Step(ctx context.Context) error { } } - // 3. Cooperative driver completions. - if stepper, ok := h.s.drv.(adapter.Stepper); ok { - for _, c := range stepper.Step(ctx, now) { - h.results <- Result{ - AttemptID: c.AttemptID, - SessionID: c.SessionID, - Status: c.Status, - Messages: c.Messages, - Err: c.Err, - Budget: c.Budget, - } - } - } + // 3. Cooperative driver completions. The driver is shared by all runs; + // route results centrally so one run cannot consume another's attempt. + h.s.collectCompletions(ctx, now) + h.results = append(h.results, h.s.takeCompletions(h)...) // 4. Drain and handle results (completions from any source). - handled := 0 - for { - select { - case res := <-h.results: - handled++ - if err := h.finishAttempt(ctx, res); err != nil { - return err - } - default: - goto drained + for len(h.results) > 0 { + res := h.results[0] + h.results = h.results[1:] + if err := h.finishAttempt(ctx, res); err != nil { + return err } } -drained: // 5. Block permanently-unrunnable nodes. @@ -422,8 +498,8 @@ drained: if h.breaker { for _, n := range h.g.Nodes { st, _ := h.tr.State(n.ID) - if st == graph.StatePending { - if err := h.transit(ctx, n.ID, graph.StatePending, graph.StateBlocked, `{"reason":"circuit breaker"}`); err != nil { + if st == graph.StatePending || st == graph.StateReady { + if err := h.transit(ctx, n.ID, st, graph.StateBlocked, `{"reason":"circuit breaker"}`); err != nil { return err } } @@ -435,8 +511,8 @@ drained: h.s.opts.RunMaxCost > 0 && h.runCost >= h.s.opts.RunMaxCost { for _, n := range h.g.Nodes { st, _ := h.tr.State(n.ID) - if st == graph.StatePending { - if err := h.transit(ctx, n.ID, graph.StatePending, graph.StateBlocked, `{"reason":"run budget exceeded"}`); err != nil { + if st == graph.StatePending || st == graph.StateReady { + if err := h.transit(ctx, n.ID, st, graph.StateBlocked, `{"reason":"run budget exceeded"}`); err != nil { return err } } @@ -445,7 +521,8 @@ drained: ready, blocked := graph.ComputeReady(h.g, h.tr) for _, n := range blocked { - if err := h.transit(ctx, n.ID, graph.StatePending, graph.StateBlocked, ""); err != nil { + state, _ := h.tr.State(n.ID) + if err := h.transit(ctx, n.ID, state, graph.StateBlocked, ""); err != nil { return err } } @@ -512,6 +589,9 @@ func (h *RunHandle) transit(ctx context.Context, id graph.NodeID, from, to graph if _, err := h.s.store.AppendTransition(ctx, h.runID, string(id), from, to, payload, h.s.clk.Now()); err != nil { return err } + if to == graph.StateFailed { + h.failures = append(h.failures, h.s.clk.Now()) + } return nil } @@ -586,6 +666,7 @@ func (h *RunHandle) startAttempt(ctx context.Context, n *graph.Node) error { } return nil } + h.s.registerAttempt(h, attemptID) started := now.UnixMilli() h.sessions[n.ID] = &sessionRec{ nodeID: n.ID, @@ -677,7 +758,7 @@ func (h *RunHandle) startCheck(ctx context.Context, n *graph.Node, attemptID str "stderr": stderr, }, } - h.results <- Result{AttemptID: attemptID, SessionID: sess.ID(), Status: adapter.StatusIdle, Messages: []adapter.Message{msg}} + h.results = append(h.results, Result{AttemptID: attemptID, SessionID: sess.ID(), Status: adapter.StatusIdle, Messages: []adapter.Message{msg}}) return h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, `{"phase":"start","sessionID":"`+sess.ID()+`"}`) } @@ -867,7 +948,7 @@ func (h *RunHandle) completeInline(ctx context.Context, n *graph.Node, attemptID "stderr": stderr, }, } - h.results <- Result{AttemptID: attemptID, SessionID: sess.ID(), Status: adapter.StatusIdle, Messages: []adapter.Message{msg}} + h.results = append(h.results, Result{AttemptID: attemptID, SessionID: sess.ID(), Status: adapter.StatusIdle, Messages: []adapter.Message{msg}}) return h.emitEvent(ctx, store.EventAttempt, n.ID, graph.State(""), graph.State(""), attemptID, `{"phase":"start","sessionID":"`+sess.ID()+`"}`) } @@ -908,7 +989,6 @@ func (h *RunHandle) RetryNode(ctx context.Context, id graph.NodeID) error { return fmt.Errorf("unknown node %s", id) } now := h.s.clk.Now() - h.breaker = false switch st { case graph.StateBlocked: if err := h.transit(ctx, id, graph.StateBlocked, graph.StateReady, `{"reason":"operator retry"}`); err != nil { @@ -942,6 +1022,8 @@ func (h *RunHandle) RetryNode(ctx context.Context, id graph.NodeID) error { default: return fmt.Errorf("node %s is in state %s and cannot be retried", id, st) } + h.breaker = false + h.failures = nil // A settled run must be re-activated. if h.done { h.done = false @@ -1076,7 +1158,17 @@ func (h *RunHandle) finishAttempt(ctx context.Context, res Result) error { if rec == nil { return fmt.Errorf("result for unknown attempt %s", res.AttemptID) } + h.s.unregisterAttempt(h, res.AttemptID) res.Budget = res.Budget || rec.abortIsBudget + if res.Budget && res.Status == adapter.StatusIdle { + // The provider may finish naturally while a budget abort is being + // retried. It is now safe to stop tracking, but work completed after the + // deadline cannot pass verification as an on-time success. + res.Status = adapter.StatusError + if res.Err == nil { + res.Err = fmt.Errorf("time budget exceeded") + } + } now := h.s.clk.Now() delete(h.sessions, rec.nodeID) delete(h.suspended, rec.nodeID) @@ -1108,6 +1200,9 @@ func (h *RunHandle) finishAttempt(ctx context.Context, res Result) error { case adapter.StatusAborted, adapter.StatusError: to := graph.StateFailed reason := `"aborted"` + if res.Status == adapter.StatusError { + reason = `"provider error"` + } if res.Err != nil { reason = `"` + jsonEscape(res.Err.Error()) + `"` } @@ -1204,7 +1299,6 @@ func (h *RunHandle) finishAttempt(ctx context.Context, res Result) error { FinishedAt: &finished, Evidence: verdict.Feedback, Cost: cost, Tokens: tokens, }) } - h.failures = append(h.failures, now) if err := h.transit(ctx, rec.nodeID, graph.StateVerifying, graph.StateFailed, ""); err != nil { return err } diff --git a/internal/store/redact_test.go b/internal/store/redact_test.go index 3c20f9c..abb108a 100644 --- a/internal/store/redact_test.go +++ b/internal/store/redact_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "path/filepath" "strings" "testing" @@ -10,6 +11,34 @@ import ( "corral/internal/graph" ) +func TestRedactPreservesJSON(t *testing.T) { + in := `{"apiKey":"abcdef","nested":{"access_token":"tokensecret1"},"items":[{"client_secret":"hunter2hunter2"}],"authorization":"Bearer abcdefgh","aws_secret_access_key":"aws-secret-value","private_key":"private-key-value","accessKeySecret":"access-key-value","large":9007199254740993,"maxTokens":123,"tokenCount":4,"promptTokens":5,"completionTokens":6,"totalTokens":11,"cachedTokens":2,"cacheCreationTokens":1,"cacheReadTokens":2}` + got := Redact(in) + if !json.Valid([]byte(got)) { + t.Fatalf("Redact returned invalid JSON: %q", got) + } + for _, secret := range []string{"abcdef", "tokensecret1", "hunter2hunter2", "abcdefgh", "aws-secret-value", "private-key-value", "access-key-value"} { + if strings.Contains(got, secret) { + t.Fatalf("Redact(%q) still contains %q: %q", in, secret, got) + } + } + if !strings.Contains(got, "9007199254740993") { + t.Fatalf("Redact changed exact JSON number: %q", got) + } + for _, accounting := range []string{`"maxTokens":123`, `"tokenCount":4`, `"promptTokens":5`, `"completionTokens":6`, `"totalTokens":11`, `"cachedTokens":2`, `"cacheCreationTokens":1`, `"cacheReadTokens":2`} { + if !strings.Contains(got, accounting) { + t.Fatalf("Redact changed non-secret accounting key %s: %q", accounting, got) + } + } +} + +func TestRedactJSONScalarString(t *testing.T) { + got := Redact(`"Bearer sk-verysecretkey1234567890"`) + if strings.Contains(got, "verysecretkey") || !json.Valid([]byte(got)) { + t.Fatalf("Redact leaked scalar JSON secret or broke JSON: %q", got) + } +} + func TestRedact(t *testing.T) { cases := []struct{ in, wantAbsent string }{ {"Authorization: Bearer sk-secret1234567890abc", "sk-secret1234567890abc"}, diff --git a/internal/store/store.go b/internal/store/store.go index 82e940a..f909238 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -651,6 +651,63 @@ func Redact(s string) string { if s == "" { return s } + if json.Valid([]byte(s)) { + var value any + decoder := json.NewDecoder(strings.NewReader(s)) + decoder.UseNumber() + if decoder.Decode(&value) == nil { + value = redactJSON(value) + if out, err := json.Marshal(value); err == nil { + return string(out) + } + } + } + return redactText(s) +} + +func redactJSON(value any) any { + switch value := value.(type) { + case map[string]any: + for key, item := range value { + if secretKey(key) { + value[key] = "[REDACTED]" + continue + } + value[key] = redactJSON(item) + } + case []any: + for i, item := range value { + value[i] = redactJSON(item) + } + case string: + return redactText(value) + } + return value +} + +func secretKey(key string) bool { + key = strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) + for _, marker := range []string{"apikey", "password", "passwd", "secret", "authorization", "privatekey", "accesskey"} { + if strings.Contains(key, marker) { + return true + } + } + // Token accounting fields are telemetry, not credentials. Other token + // keys (accessToken, refreshToken, tokenValue, etc.) are secrets. + accounting := map[string]bool{ + "tokens": true, "tokencount": true, "maxtokens": true, + "prompttokens": true, "completiontokens": true, "totaltokens": true, + "inputtokens": true, "outputtokens": true, "reasoningtokens": true, + "cachedtokens": true, "cachecreationtokens": true, + "cachereadtokens": true, "cachewritetokens": true, + } + if strings.Contains(key, "token") && !accounting[key] { + return true + } + return false +} + +func redactText(s string) string { repl := []struct{ re, to string }{ {`(?i)bearer\s+[A-Za-z0-9._~+/=-]+`, "bearer [REDACTED]"}, {`(?i)api[_-]?key["']?\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{6,}`, "apiKey [REDACTED]"}, diff --git a/internal/tui/notify_test.go b/internal/tui/notify_test.go index d1ab919..b7a84d5 100644 --- a/internal/tui/notify_test.go +++ b/internal/tui/notify_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "runtime" "strings" "testing" "unicode/utf8" @@ -16,12 +17,18 @@ func TestDesktopNotificationDoesNotEmbedInputInAppleScript(t *testing.T) { // Unsupported host. Construction is platform-dependent. return } - if len(cmd.Args) < 4 { - t.Fatalf("notification command args: %q", cmd.Args) - } - script := cmd.Args[2] - if strings.Contains(script, injection) || strings.Contains(script, "touch /tmp/pwned") { - t.Fatalf("untrusted input embedded in program source: %q", script) + if runtime.GOOS == "darwin" { + if len(cmd.Args) < 5 { + t.Fatalf("notification command args: %q", cmd.Args) + } + script := cmd.Args[2] + if strings.Contains(script, injection) || strings.Contains(script, "touch /tmp/pwned") { + t.Fatalf("untrusted input embedded in program source: %q", script) + } + } else if runtime.GOOS == "linux" { + if len(cmd.Args) != 4 || cmd.Args[1] != "--" { + t.Fatalf("unsafe notify-send argv: %q", cmd.Args) + } } if cmd.Args[len(cmd.Args)-2] != injection || cmd.Args[len(cmd.Args)-1] != injection { t.Fatalf("notification text not passed as data argv: %q", cmd.Args) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 03f8a7b..864da21 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -406,6 +406,83 @@ func TestBudgetBarInDetail(t *testing.T) { if !strings.Contains(view, "1s/2s") { t.Fatalf("detail view missing budget usage:\n%s", view) } + if !strings.Contains(view, "wall elapsed") { + t.Fatalf("detail view must label timestamp-only duration as wall elapsed:\n%s", view) + } +} + +func TestBudgetBarExcludesPermissionWaitFromProviderRuntime(t *testing.T) { + d := sampleDetail() + n := &d.Graph.Nodes[0] + n.Budget.MaxDuration = int64(10 * time.Second) + d.Attempts["w1"] = []AttemptView{{ + ID: "run_1/w1/1", No: 1, Status: "done", + StartedAt: int64Ptr(1_000), FinishedAt: int64Ptr(26_000), + }} + d.Events = []EventView{ + {Seq: 1, NodeID: "w1", Type: "attempt", AttemptID: "run_1/w1/1", CreatedAt: 1_000}, + {Seq: 2, NodeID: "w1", Type: "transition", From: "running", To: "blocked", CreatedAt: 3_000}, + {Seq: 3, NodeID: "w1", Type: "transition", From: "blocked", To: "ready", CreatedAt: 23_000}, + {Seq: 4, NodeID: "w1", Type: "transition", From: "ready", To: "leased", CreatedAt: 23_000}, + {Seq: 5, NodeID: "w1", Type: "transition", From: "leased", To: "running", CreatedAt: 23_000}, + {Seq: 6, NodeID: "w1", Type: "transition", From: "running", To: "verifying", CreatedAt: 26_000}, + } + m := New(&fakeAPI{}, context.Background()) + m.detail = d + bar := m.nodeBudgetBar(*n, d.Attempts["w1"], "done") + if !strings.Contains(bar, "runtime 5s/10s") { + t.Fatalf("permission wait counted as provider runtime: %q", bar) + } +} + +func TestElapsedActiveAttemptUsesCurrentTime(t *testing.T) { + start := time.Now().Add(-2 * time.Second).UnixMilli() + got := elapsed(start, nil) + if got == "0ms" { + t.Fatal("active attempt elapsed time must advance") + } +} + +func TestInspectSanitizesProviderTerminalControls(t *testing.T) { + d := sampleDetail() + d.States["w1"] = "running" + d.Graph.Nodes[0].Objective = "safe\x1b]52;c;Y2xpcGJvYXJk\a\x1b[31mred\x1b[0m" + d.Attempts["w1"][0].Status = "run\x1b]52;c;c3RhdHVz\a\x1b[31mning\x1b[0m" + d.Attempts["w1"][0].Evidence = "proof\x1b]0;title\a" + m := New(&fakeAPI{}, context.Background()) + m.selectedID, m.inspectNode, m.detail, m.mode = "run_1", "w1", d, modeInspect + m.tail = []string{"tail\x1b]52;c;ZXZpbA==\a\x1b[2Jvisible"} + view := m.View() + for _, unsafe := range []string{"\x1b]52", "\x1b[31m", "\x1b[2J", "\a"} { + if strings.Contains(view, unsafe) { + t.Fatalf("view retained terminal control %q: %q", unsafe, view) + } + } + for _, want := range []string{"safe", "red", "proof", "tail", "visible"} { + if !strings.Contains(view, want) { + t.Fatalf("view lost safe text %q: %q", want, view) + } + } +} + +func TestCursorTargetsRenderedGraphOrder(t *testing.T) { + d := sampleDetail() + // Input order differs from the stable ID order rendered by viewDetail. + d.Graph.Nodes = []GraphNode{d.Graph.Nodes[2], d.Graph.Nodes[0], d.Graph.Nodes[1]} + api := &fakeAPI{} + m := New(api, context.Background()) + m.selectedID, m.detail, m.mode, m.nodeCursor = "run_1", d, modeDetail, 0 + if selected, ok := m.SelectedNode(); !ok || selected != "m" { + t.Fatalf("selected node = %q, %v; want first rendered node m", selected, ok) + } + _, cmd := m.Update(key("a")) + if cmd == nil { + t.Fatal("approve command missing") + } + cmd() + if len(api.actions) != 1 || api.actions[0] != "approve:m" { + t.Fatalf("cursor action = %v, want approve:m", api.actions) + } } func TestBudgetBarUsesHighestUtilizationAndDoesNotFillDone(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index 96e6080..79b3072 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -5,8 +5,10 @@ import ( "sort" "strings" "time" + "unicode" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) var ( @@ -73,6 +75,23 @@ func (m *Model) View() string { return "" } +func stripUnsafeControls(s string) string { + return strings.Map(func(r rune) rune { + switch r { + case '\n', '\t': + return r + } + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} + +func safeText(s string) string { + return stripUnsafeControls(ansi.Strip(s)) +} + func (m *Model) viewList() string { var b strings.Builder b.WriteString(styleHeader.Render("corral — runs") + "\n\n") @@ -80,7 +99,7 @@ func (m *Model) viewList() string { b.WriteString(styleDim.Render("no runs yet; start one from OpenCode (corral_start) or the daemon API") + "\n") } else { for i, r := range m.runs { - line := fmt.Sprintf(" %-28s %-10s %s", r.ID, r.Status, r.StateSummary()) + line := fmt.Sprintf(" %-28s %-10s %s", safeText(r.ID), safeText(r.Status), r.StateSummary()) if i == m.cursor { line = styleSelected.Render(line) } @@ -99,7 +118,7 @@ func (m *Model) viewDetail() string { return "loading…" } var b strings.Builder - b.WriteString(styleTitle.Render(fmt.Sprintf("run %s [%s]", m.detail.RunID, m.detail.Status))) + b.WriteString(styleTitle.Render(fmt.Sprintf("run %s [%s]", safeText(m.detail.RunID), safeText(m.detail.Status)))) if m.detail.Status == "completed" { b.WriteString(styleOK.Render(" ✓ done")) } @@ -127,15 +146,12 @@ func (m *Model) viewDetail() string { for _, n := range nodes { byID[n.ID] = n } - // Topological-ish order: by state then id for stable rendering. - order := append([]GraphNode(nil), nodes...) - sort.SliceStable(order, func(i, j int) bool { return order[i].ID < order[j].ID }) indeg := map[string]int{} - for _, n := range order { + for _, n := range nodes { indeg[n.ID] = len(n.DependsOn) } selected, _ := m.nodeAt(m.nodeCursor) - for _, n := range order { + for _, n := range nodes { line := " " + m.nodeLine(n, indeg[n.ID]) if n.ID == selected { line = styleSelected.Render(line) @@ -143,7 +159,7 @@ func (m *Model) viewDetail() string { b.WriteString(line + "\n") for _, dep := range n.DependsOn { if dn, ok := byID[dep]; ok { - b.WriteString(styleDim.Render(fmt.Sprintf(" ← %s (%s)\n", dep, m.detail.States[dep]))) + b.WriteString(styleDim.Render(fmt.Sprintf(" ← %s (%s)\n", safeText(dep), safeText(m.detail.States[dep])))) _ = dn } } @@ -154,8 +170,8 @@ func (m *Model) viewDetail() string { func (m *Model) nodeLine(n GraphNode, deps int) string { state := m.detail.States[n.ID] - st := stateColor(state).Render(pad(state, 10)) - typ := styleDim.Render(n.Type) + st := stateColor(state).Render(pad(safeText(state), 10)) + typ := styleDim.Render(safeText(n.Type)) prio := styleDim.Render(fmt.Sprintf("p%d", n.Priority)) atts := m.detail.Attempts[n.ID] attempts := styleDim.Render(fmt.Sprintf("%d att", len(atts))) @@ -166,16 +182,16 @@ func (m *Model) nodeLine(n GraphNode, deps int) string { permS := "" if state == "blocked" { if pid, ok := m.pendingPermission(n.ID); ok { - permS = styleTitle.Render(fmt.Sprintf("perm:%s", pid)) + permS = styleTitle.Render(fmt.Sprintf("perm:%s", safeText(pid))) } } bar := m.nodeBudgetBar(n, atts, state) - return fmt.Sprintf("%-12s %s %-7s %s %s %s %s %s", n.ID, st, typ, prio, attempts, depsS, bar, permS) + return fmt.Sprintf("%-12s %s %-7s %s %s %s %s %s", safeText(n.ID), st, typ, prio, attempts, depsS, bar, permS) } // nodeBudgetBar renders a compact budget-usage bar for a node. The -// dominant budget dimension (time, tokens, or cost) drives the bar, with -// the used/limit figures beside it. Nodes without a budget show "". +// dominant budget dimension (provider runtime, tokens, or cost) drives the +// bar, with the used/limit figures beside it. Nodes without a budget show "". func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, _ string) string { maxDur := n.Budget.MaxDuration maxTok := n.Budget.MaxTokens @@ -183,23 +199,26 @@ func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, _ string) string if maxDur <= 0 && maxTok <= 0 && maxCost <= 0 { return "" } - // Used figures, from the most recent attempt for time and the sum for + // Used figures, from the most recent attempt for runtime and the sum for // tokens/cost. var usedDur time.Duration + timeLabel := "wall elapsed" usedTok := 0 usedCost := 0.0 if len(atts) > 0 { at := atts[len(atts)-1] - start := m.now() - if at.StartedAt != nil { - start = time.UnixMilli(*at.StartedAt) - } - end := m.now() - if at.FinishedAt != nil { - end = time.UnixMilli(*at.FinishedAt) - } - if end.After(start) { - usedDur = end.Sub(start) + if runtime, exact := m.attemptRuntime(n.ID, at); exact { + usedDur = runtime + timeLabel = "runtime" + } else if at.StartedAt != nil { + start := time.UnixMilli(*at.StartedAt) + end := m.now() + if at.FinishedAt != nil { + end = time.UnixMilli(*at.FinishedAt) + } + if end.After(start) { + usedDur = end.Sub(start) + } } } for _, at := range atts { @@ -222,7 +241,7 @@ func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, _ string) string if maxDur > 0 { consider(usage{ fraction: float64(usedDur) / float64(time.Duration(maxDur)), - label: fmt.Sprintf("time %s/%s", usedDur.Round(time.Second), time.Duration(maxDur).Round(time.Second)), + label: fmt.Sprintf("%s %s/%s", timeLabel, usedDur.Round(time.Second), time.Duration(maxDur).Round(time.Second)), }) } if maxTok > 0 { @@ -240,6 +259,59 @@ func (m *Model) nodeBudgetBar(n GraphNode, atts []AttemptView, _ string) string return progressBar(dominant.fraction, 8) + " " + styleMuted.Render(dominant.label) } +// attemptRuntime sums intervals in which the provider session was running. +// Durable transition events make permission-blocked intervals visible, so +// those waits do not consume the displayed runtime budget. Older/incomplete +// event histories return exact=false and are shown explicitly as wall elapsed. +func (m *Model) attemptRuntime(nodeID string, at AttemptView) (time.Duration, bool) { + if m.detail == nil || at.StartedAt == nil { + return 0, false + } + var startSeq int64 + for _, event := range m.detail.Events { + if event.Type == "attempt" && event.AttemptID == at.ID { + startSeq = event.Seq + break + } + } + if startSeq == 0 { + return 0, false + } + + start := *at.StartedAt + end := m.now().UnixMilli() + if at.FinishedAt != nil { + end = *at.FinishedAt + } + if end < start { + end = start + } + + active := true + activeSince := start + var usedMillis int64 + for _, event := range m.detail.Events { + if event.Seq <= startSeq || event.NodeID != nodeID || event.Type != "transition" || event.CreatedAt < start || event.CreatedAt > end { + continue + } + if active && event.From == "running" && event.To != "running" { + if event.CreatedAt > activeSince { + usedMillis += event.CreatedAt - activeSince + } + active = false + continue + } + if !active && event.To == "running" { + active = true + activeSince = event.CreatedAt + } + } + if active && end > activeSince { + usedMillis += end - activeSince + } + return time.Duration(usedMillis) * time.Millisecond, true +} + // now returns the model's wall-clock reference (last tick time, or real // time before the first tick) for elapsed computations. func (m *Model) now() time.Time { @@ -259,28 +331,36 @@ func (m *Model) viewInspect() string { return "node gone" } state := m.detail.States[n.ID] - b.WriteString(styleTitle.Render(fmt.Sprintf("node %s %s", n.ID, stateColor(state).Render(state))) + "\n\n") - b.WriteString(styleDim.Render("objective: ") + n.Objective + "\n") + b.WriteString(styleTitle.Render(fmt.Sprintf("node %s %s", safeText(n.ID), stateColor(state).Render(safeText(state)))) + "\n\n") + b.WriteString(styleDim.Render("objective: ") + safeText(n.Objective) + "\n") if n.Role != "" { - b.WriteString(styleDim.Render("role: ") + n.Role + "\n") + b.WriteString(styleDim.Render("role: ") + safeText(n.Role) + "\n") } if len(n.WriteScope) > 0 { - b.WriteString(styleDim.Render("write scope: ") + strings.Join(n.WriteScope, ", ") + "\n") + scopes := make([]string, len(n.WriteScope)) + for i, scope := range n.WriteScope { + scopes[i] = safeText(scope) + } + b.WriteString(styleDim.Render("write scope: ") + strings.Join(scopes, ", ") + "\n") } if n.Verification != nil { - b.WriteString(styleDim.Render("verification: ") + n.Verification.Kind + " " + strings.Join(n.Verification.Command, " ") + "\n") + command := make([]string, len(n.Verification.Command)) + for i, arg := range n.Verification.Command { + command[i] = safeText(arg) + } + b.WriteString(styleDim.Render("verification: ") + safeText(n.Verification.Kind) + " " + strings.Join(command, " ") + "\n") } if pid, ok := m.pendingPermission(n.ID); ok { - b.WriteString(styleDim.Render("permission: ") + styleTitle.Render(pid) + styleMuted.Render(" pending — p allow · d deny") + "\n") + b.WriteString(styleDim.Render("permission: ") + styleTitle.Render(safeText(pid)) + styleMuted.Render(" pending — p allow · d deny") + "\n") } b.WriteString("\n" + styleDim.Render("attempts") + "\n") for _, at := range m.detail.Attempts[n.ID] { - b.WriteString(fmt.Sprintf(" #%d %-10s", at.No, stateColor(at.Status).Render(at.Status))) + b.WriteString(fmt.Sprintf(" #%d %-10s", at.No, stateColor(at.Status).Render(safeText(at.Status)))) if at.SessionID != "" { - b.WriteString(styleMuted.Render(" session=" + at.SessionID)) + b.WriteString(styleMuted.Render(" session=" + safeText(at.SessionID))) } if at.Worktree != "" { - b.WriteString(styleMuted.Render(" worktree=" + shortPath(at.Worktree))) + b.WriteString(styleMuted.Render(" worktree=" + shortPath(safeText(at.Worktree)))) } if at.StartedAt != nil { b.WriteString(styleMuted.Render(" " + elapsed(*at.StartedAt, at.FinishedAt))) @@ -290,7 +370,7 @@ func (m *Model) viewInspect() string { b.WriteString(styleMuted.Render(fmt.Sprintf(" $%.4f %d tok\n", at.Cost, at.Tokens))) } if at.Evidence != "" { - b.WriteString(styleMuted.Render(" evidence: "+shortLine(at.Evidence, 90)) + "\n") + b.WriteString(styleMuted.Render(" evidence: "+shortLine(safeText(at.Evidence), 90)) + "\n") } } // Live attempt tail for the current (running) attempt. @@ -300,7 +380,7 @@ func (m *Model) viewInspect() string { b.WriteString(styleMuted.Render(" (no output yet)\n")) } else { for _, ln := range m.tail { - b.WriteString(styleMuted.Render(" "+shortLine(ln, 90)) + "\n") + b.WriteString(styleMuted.Render(" "+shortLine(safeText(ln), 90)) + "\n") } } } @@ -310,8 +390,8 @@ func (m *Model) viewInspect() string { func (m *Model) viewSteer() string { var b strings.Builder - b.WriteString(styleTitle.Render(fmt.Sprintf("steer %s/%s", m.selectedID, m.steerNode)) + "\n\n") - b.WriteString("message: " + m.steerInput + "▌\n\n") + b.WriteString(styleTitle.Render(fmt.Sprintf("steer %s/%s", safeText(m.selectedID), safeText(m.steerNode))) + "\n\n") + b.WriteString("message: " + safeText(m.steerInput) + "▌\n\n") b.WriteString(m.footer("type message · enter send · esc cancel")) return b.String() } @@ -320,10 +400,10 @@ func (m *Model) footer(keys string) string { var b strings.Builder b.WriteString(styleDim.Render("── " + keys)) if m.status != "" { - b.WriteString(styleOK.Render(" · " + m.status)) + b.WriteString(styleOK.Render(" · " + safeText(m.status))) } if m.err != nil { - b.WriteString(styleError.Render(" · " + m.err.Error())) + b.WriteString(styleError.Render(" · " + safeText(m.err.Error()))) } return b.String() } @@ -343,7 +423,7 @@ func (m *Model) findNode(id string) *GraphNode { func (r RunSummary) StateSummary() string { var parts []string for _, id := range sortedKeys(r.States) { - parts = append(parts, fmt.Sprintf("%s:%s", id, stateColor(r.States[id]).Render(r.States[id]))) + parts = append(parts, fmt.Sprintf("%s:%s", safeText(id), stateColor(r.States[id]).Render(safeText(r.States[id])))) } return strings.Join(parts, " ") } @@ -380,11 +460,14 @@ func shortPath(p string) string { } func elapsed(start int64, end *int64) string { - e := start + e := time.Now().UnixMilli() if end != nil { e = *end } d := time.Duration(e-start) * time.Millisecond + if d < 0 { + d = 0 + } if d < time.Second { return fmt.Sprintf("%dms", d.Milliseconds()) } diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 14dab2a..f2baa17 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -122,9 +122,23 @@ func (m *Manager) CommitWorktree(ctx context.Context, worktree string) error { if _, err := m.git(ctx, worktree, "add", "-A"); err != nil { return err } - if _, err := m.git(ctx, worktree, "-c", "user.name=corral", "-c", "user.email=corral@local", "commit", "-q", "-m", "corral: work"); err != nil { - // Nothing to commit is fine. + // Determine the no-op case explicitly. A non-zero commit can also mean + // a hook, signer, or filesystem failure; those errors must reach the + // caller so the staged work can be retried. + out, code, err := m.gitExit(ctx, worktree, "diff", "--cached", "--quiet", "--exit-code", "HEAD", "--") + if err != nil { + return err + } + switch code { + case 0: return nil + case 1: + // Staged changes exist; commit them below. + default: + return fmt.Errorf("inspect staged work: git diff exited %d: %s", code, tail([]byte(out), 400)) + } + if _, err := m.git(ctx, worktree, "-c", "user.name=corral", "-c", "user.email=corral@local", "commit", "-q", "-m", "corral: work"); err != nil { + return err } return nil } @@ -140,15 +154,37 @@ func (m *Manager) MergeBranch(ctx context.Context, branch string) error { if _, err := m.git(ctx, m.repo, "checkout", "-q", main); err != nil { return err } - out, err := m.git(ctx, m.repo, + _, err = m.git(ctx, m.repo, "-c", "user.name=corral", "-c", "user.email=corral@local", "merge", "--no-ff", "-m", "corral: merge "+branch, branch) if err != nil { - return fmt.Errorf("merge %s: %w: %s", branch, err, tail([]byte(out), 400)) + mergeErr := fmt.Errorf("merge %s: %w", branch, err) + if abortErr := m.abortMerge(); abortErr != nil { + return fmt.Errorf("%w; abort failed: %v", mergeErr, abortErr) + } + return mergeErr } return nil } +// abortMerge restores the main checkout after a merge that entered merge +// state and then failed (for example, a conflict or merge-commit hook). +// Cleanup uses its own bounded context so caller cancellation cannot strand +// the repository in an active merge. +func (m *Manager) abortMerge() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, code, err := m.gitExit(ctx, m.repo, "rev-parse", "--verify", "--quiet", "MERGE_HEAD") + if err != nil { + return err + } + if code != 0 { + return nil + } + _, err = m.git(ctx, m.repo, "merge", "--abort") + return err +} + // MainBranch returns the currently checked-out branch of the main repo. func (m *Manager) MainBranch(ctx context.Context) (string, error) { out, err := m.git(ctx, m.repo, "branch", "--show-current") @@ -162,14 +198,21 @@ func (m *Manager) MainBranch(ctx context.Context) (string, error) { return b, nil } -// Remove deletes a worktree. Failed worktrees are kept for inspection by -// design; callers invoke Remove only after successful merge/cleanup. +// Remove deletes a clean worktree. Any tracked, untracked, or ignored content +// keeps the worktree for inspection, even after its branch was merged. func (m *Manager) Remove(ctx context.Context, branch string) error { path := filepath.Join(m.dir, branch) if _, err := os.Stat(path); err != nil { return nil // already gone } - if _, err := m.git(ctx, m.repo, "worktree", "remove", "--force", path); err != nil { + status, err := m.git(ctx, path, "status", "--porcelain=v1", "--untracked-files=normal", "--ignored=matching") + if err != nil { + return err + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf("refuse to remove dirty worktree %s", path) + } + if _, err := m.git(ctx, m.repo, "worktree", "remove", path); err != nil { return err } _, _ = m.git(ctx, m.repo, "branch", "-D", branch) @@ -185,7 +228,7 @@ type WorktreeInfo struct { Locked bool Detached bool Orphaned bool // admin entry whose working directory is already gone - Dirty bool // tracked, staged, or untracked work not recorded in HEAD + Dirty bool // tracked, staged, untracked, or ignored content not recorded in HEAD } // List returns the attempt worktrees registered under the manager's @@ -204,7 +247,9 @@ func (m *Manager) List(ctx context.Context) ([]WorktreeInfo, error) { continue } info.Mtime = lastActivity(info.Path) - status, err := m.git(ctx, info.Path, "status", "--porcelain=v1", "--untracked-files=normal", "--ignored=no") + // Ignored files still carry user data. Treat them as dirty so automatic + // pruning never deletes content merely because .gitignore hides it. + status, err := m.git(ctx, info.Path, "status", "--porcelain=v1", "--untracked-files=normal", "--ignored=matching") if err != nil { return nil, err } @@ -226,7 +271,7 @@ func parseWorktreeBlock(block string) (WorktreeInfo, bool) { info.Branch = strings.TrimPrefix(line, "branch refs/heads/") case line == "detached": info.Detached = true - case line == "locked": + case line == "locked" || strings.HasPrefix(line, "locked "): info.Locked = true case strings.HasPrefix(line, "prunable"): info.Orphaned = true @@ -357,7 +402,9 @@ func (m *Manager) Prune(ctx context.Context, staleAfter time.Duration, now time. // removeInfo removes a worktree by path. A merged branch is deleted too; // an unmerged stale branch is kept so its commits stay recoverable. func (m *Manager) removeInfo(ctx context.Context, info WorktreeInfo, merged bool) error { - if _, err := m.git(ctx, m.repo, "worktree", "remove", "--force", info.Path); err != nil { + // No --force here: if content appears after List's safety check, Git must + // refuse instead of deleting an agent's newly-written work. + if _, err := m.git(ctx, m.repo, "worktree", "remove", info.Path); err != nil { return err } if merged && info.Branch != "" { diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index d5577cf..05a4297 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" ) @@ -100,6 +101,216 @@ func TestManagerLifecycle(t *testing.T) { } } +func TestRemovePreservesIgnoredContent(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("secret.env\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "ignore"); err != nil { + t.Fatal(err) + } + branch := "corral/r1/w1/1" + path, err := m.Add(ctx, branch) + if err != nil { + t.Fatal(err) + } + secret := filepath.Join(path, "secret.env") + if err := os.WriteFile(secret, []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + if err := m.Remove(ctx, branch); err == nil || !strings.Contains(err.Error(), "dirty worktree") { + t.Fatalf("Remove ignored-content error = %v", err) + } + if got, err := os.ReadFile(secret); err != nil || string(got) != "keep me" { + t.Fatalf("ignored content lost: %q, %v", got, err) + } +} + +func TestCommitWorktreePropagatesCommitFailureAndCanRetry(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + hook := filepath.Join(repo, ".git", "hooks", "pre-commit") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + + if err := m.CommitWorktree(ctx, path); err == nil { + t.Fatal("CommitWorktree hid a real git commit failure") + } + status, err := m.git(ctx, path, "status", "--porcelain=v1") + if err != nil { + t.Fatal(err) + } + if status != "A a.txt\n" { + t.Fatalf("failed commit did not preserve staged work: %q", status) + } + mainStatus, err := m.git(ctx, repo, "status", "--porcelain=v1") + if err != nil { + t.Fatal(err) + } + if mainStatus != "" { + t.Fatalf("failed worktree commit dirtied main: %q", mainStatus) + } + + if err := os.Remove(hook); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatalf("retry commit: %v", err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatalf("merge after retry: %v", err) + } +} + +func TestCommitWorktreeAllowsNothingToCommit(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatalf("empty commit: %v", err) + } +} + +func TestMergeBranchAbortsConflictAndCanRetry(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + if err := os.WriteFile(filepath.Join(repo, "conflict.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "add", "conflict.txt"); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "base"); err != nil { + t.Fatal(err) + } + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "conflict.txt"), []byte("branch\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "conflict.txt"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "add", "conflict.txt"); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "main change"); err != nil { + t.Fatal(err) + } + mainHead, err := m.git(ctx, repo, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err == nil { + t.Fatal("conflicting merge unexpectedly succeeded") + } + assertCleanMainWithoutMerge(t, m, ctx, mainHead) + + // Fold main into the worktree branch, making a retry conflict-free. + if _, err := m.git(ctx, path, + "-c", "user.name=t", "-c", "user.email=t@t", + "merge", "-q", "-s", "ours", "-m", "resolve main", "main"); err != nil { + t.Fatal(err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatalf("merge retry: %v", err) + } +} + +func TestMergeBranchAbortsFailedMergeCommitAndCanRetry(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.CommitWorktree(ctx, path); err != nil { + t.Fatal(err) + } + hook := filepath.Join(repo, ".git", "hooks", "pre-merge-commit") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + mainHead, err := m.git(ctx, repo, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err == nil { + t.Fatal("merge commit hook failure was hidden") + } + assertCleanMainWithoutMerge(t, m, ctx, mainHead) + + if err := os.Remove(hook); err != nil { + t.Fatal(err) + } + if err := m.MergeBranch(ctx, "corral/r1/w1/1"); err != nil { + t.Fatalf("merge retry: %v", err) + } +} + +func assertCleanMainWithoutMerge(t *testing.T, m *Manager, ctx context.Context, wantHead string) { + t.Helper() + if _, code, err := m.gitExit(ctx, m.repo, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err != nil { + t.Fatal(err) + } else if code == 0 { + t.Fatal("failed merge left MERGE_HEAD active") + } + status, err := m.git(ctx, m.repo, "status", "--porcelain=v1") + if err != nil { + t.Fatal(err) + } + if status != "" { + t.Fatalf("failed merge left main dirty: %q", status) + } + head, err := m.git(ctx, m.repo, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + if head != wantHead { + t.Fatalf("failed merge moved main HEAD: got %q want %q", head, wantHead) + } +} + func TestManagerList(t *testing.T) { repo := t.TempDir() gitInit(t, repo) @@ -306,6 +517,40 @@ func TestManagerPruneSkipsDirtyStale(t *testing.T) { } } +func TestManagerPruneSkipsIgnoredContent(t *testing.T) { + repo := t.TempDir() + gitInit(t, repo) + ctx := context.Background() + m := NewManager(repo) + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("secret.env\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if _, err := m.git(ctx, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "ignore secret"); err != nil { + t.Fatal(err) + } + path, err := m.Add(ctx, "corral/r1/w1/1") + if err != nil { + t.Fatal(err) + } + secret := filepath.Join(path, "secret.env") + if err := os.WriteFile(secret, []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + pruned, err := m.Prune(ctx, time.Hour, time.Now().Add(30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(pruned) != 0 { + t.Fatalf("worktree with ignored content pruned: %v", pruned) + } + if got, err := os.ReadFile(secret); err != nil || string(got) != "keep me" { + t.Fatalf("ignored content lost: %q, %v", got, err) + } +} + func TestManagerPruneSkipsLocked(t *testing.T) { repo := t.TempDir() gitInit(t, repo) @@ -319,7 +564,7 @@ func TestManagerPruneSkipsLocked(t *testing.T) { if err := os.WriteFile(filepath.Join(path, "a.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } - if _, err := m.git(ctx, m.repo, "worktree", "lock", path); err != nil { + if _, err := m.git(ctx, m.repo, "worktree", "lock", "--reason", "active run", path); err != nil { t.Fatal(err) } if err := m.CommitWorktree(ctx, path); err != nil { From a1d8baf3c7de29fa95d49301b40317c2ea6f81a4 Mon Sep 17 00:00:00 2001 From: Thiago Schweder Souza Date: Thu, 13 Aug 2026 11:36:56 -0300 Subject: [PATCH 26/26] fix: close agent authorization boundaries --- .opencode/tools/corral.ts | 5 +- README.md | 6 +- cmd/corral/main.go | 20 +- cmd/corral/main_test.go | 20 +- docs/task6-plugin.md | 18 +- example/opencode.json | 21 +- expense-report-workflow.excalidraw | 2617 ++++++++++++++++++++++++ expense-report-workflow.png | Bin 0 -> 435173 bytes internal/adapter/adapter.go | 13 + internal/assets/assets_test.go | 50 + internal/assets/corral.ts | 5 +- internal/assets/opencode.json | 21 +- internal/claudeadapter/adapter.go | 18 +- internal/claudeadapter/adapter_test.go | 4 + internal/claudeadapter/permission.go | 17 + internal/daemon/daemon.go | 7 + internal/daemon/daemon_test.go | 6 + internal/daemon/planner.go | 14 +- internal/daemon/planner_test.go | 6 + internal/graph/graph_test.go | 22 + internal/graph/validate.go | 35 + internal/ocxadapter/adapter.go | 9 +- internal/sched/sched.go | 35 +- internal/sched/worktree_test.go | 21 + internal/spike/server.go | 12 + internal/tui/model.go | 23 +- internal/tui/tui_test.go | 10 +- internal/tui/view.go | 16 +- internal/worktree/worktree.go | 32 + 29 files changed, 3015 insertions(+), 68 deletions(-) create mode 100644 expense-report-workflow.excalidraw create mode 100644 expense-report-workflow.png diff --git a/.opencode/tools/corral.ts b/.opencode/tools/corral.ts index 38d22a6..0c05691 100644 --- a/.opencode/tools/corral.ts +++ b/.opencode/tools/corral.ts @@ -70,7 +70,6 @@ export const start = tool({ description: "Start a corral run from an approved graph.", args: { graph: tool.schema.string().describe("Graph JSON (as returned by corral_plan)"), - autoApproveGates: tool.schema.boolean().optional().describe("When true, the run is pre-authorized: the orchestrator approves human gates itself as they are reached, without waiting for the operator"), }, async execute(args, context) { let parsed: unknown @@ -83,9 +82,7 @@ export const start = tool({ typeof parsed === "object" && parsed !== null && "graph" in parsed ? (parsed as { graph: unknown }).graph : parsed - const body: Record = { graph } - if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates - return call("/api/runs", body, roleFor(context.agent)) + return call("/api/runs", { graph }, roleFor(context.agent)) }, }) diff --git a/README.md b/README.md index f337eec..13737fa 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,9 @@ Inside OpenCode: 2. Review the returned graph. 3. Switch to `corral-orchestrator` and ask it to start that graph. 4. Follow progress with `corral_status` / `corral_watch`; approve, reject, - retry, cancel, or steer nodes when needed. `corral_start` accepts an - optional `autoApproveGates` flag that pre-authorizes the orchestrator to call - the normal gate approval endpoint without waiting for the operator. + retry, cancel, or steer nodes when needed. Trusted operator API clients may + set `autoApproveGates` when creating a run; model agents cannot grant that + authority to themselves. Or follow the same run from the terminal: diff --git a/cmd/corral/main.go b/cmd/corral/main.go index fa141dd..f848e0b 100644 --- a/cmd/corral/main.go +++ b/cmd/corral/main.go @@ -132,7 +132,7 @@ func daemonCmd(port int, apiKey string) error { // restart it on the same URL without breaking the adapter clients. servePort := freePort() var ocMu sync.Mutex - ocServer, err := spike.StartServer(ctx, dir, servePort, os.Stderr) + ocServer, err := spike.StartServerWithConfig(ctx, dir, servePort, os.Stderr, assets.OpenCodeConfigJSON) if err != nil { return fmt.Errorf("start opencode server: %w", err) } @@ -151,7 +151,7 @@ func daemonCmd(port int, apiKey string) error { return } time.Sleep(2 * time.Second) - ns, err := spike.StartServer(ctx, dir, servePort, os.Stderr) + ns, err := spike.StartServerWithConfig(ctx, dir, servePort, os.Stderr, assets.OpenCodeConfigJSON) if err != nil { log.Printf("opencode server restart failed: %v", err) return @@ -456,7 +456,9 @@ func initCmd(wantDir string) error { } fmt.Println("api key written:", keyFile) } - cfg := map[string]any{"dir": dir, "apiKey": key, "daemonURL": daemonURL()} + // Keep the bearer token only in the mode-0600 api.key file. Config is + // project-readable metadata and must never duplicate authentication data. + cfg := map[string]any{"dir": dir, "daemonURL": daemonURL()} if err := writeJSONFile(filepath.Join(corralDir, "config.json"), cfg); err != nil { return err } @@ -489,7 +491,8 @@ func installPlugin(dir string) error { // installAgentConfig merges the corral agents (planner, orchestrator, // worker, reviewer, merger) into the project's opencode.json, preserving -// any existing configuration. Existing agent entries are left untouched. +// unrelated configuration. Managed agent definitions are refreshed on every +// init so an older permissive policy cannot bypass current role boundaries. func installAgentConfig(dir string) error { cfgPath := filepath.Join(dir, "opencode.json") existing := map[string]any{} @@ -513,9 +516,14 @@ func installAgentConfig(dir string) error { existingAgents = map[string]any{} } for name, def := range agents { - if _, ok := existingAgents[name]; !ok { - existingAgents[name] = def + // Model selection is a safe user customization. All authority-bearing + // fields (permission/tools/mode) come from the embedded definition. + if current, ok := existingAgents[name].(map[string]any); ok { + if model, ok := current["model"]; ok { + def.(map[string]any)["model"] = model + } } + existingAgents[name] = def } existing["agent"] = existingAgents return writeJSONFile(cfgPath, existing) diff --git a/cmd/corral/main_test.go b/cmd/corral/main_test.go index 26ede9b..5b20cc8 100644 --- a/cmd/corral/main_test.go +++ b/cmd/corral/main_test.go @@ -221,7 +221,14 @@ func TestInitMergesExistingOpenCodeConfig(t *testing.T) { // Pre-existing opencode.json with custom settings must be preserved. existing := `{ "theme": "dark", - "agent": {"build": {"model": "custom/model"}} + "agent": { + "build": {"model": "custom/model"}, + "corral-orchestrator": { + "model": "custom/orchestrator", + "tools": {"bash": true}, + "permission": {"*": "allow"} + } + } }` if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte(existing), 0o644); err != nil { t.Fatal(err) @@ -246,6 +253,17 @@ func TestInitMergesExistingOpenCodeConfig(t *testing.T) { t.Fatalf("corral agent %s missing: %s", name, data) } } + orchestrator := agents["corral-orchestrator"].(map[string]any) + if orchestrator["model"] != "custom/orchestrator" { + t.Fatalf("safe model customization lost: %s", data) + } + if _, ok := orchestrator["tools"]; ok { + t.Fatalf("legacy permissive tools survived managed-agent refresh: %s", data) + } + permission := orchestrator["permission"].(map[string]any) + if permission["*"] != "deny" || permission["corral_start"] != "allow" { + t.Fatalf("orchestrator policy was not hardened: %s", data) + } } func TestInitBacksUpInvalidConfig(t *testing.T) { diff --git a/docs/task6-plugin.md b/docs/task6-plugin.md index bcdd9e7..5ad0897 100644 --- a/docs/task6-plugin.md +++ b/docs/task6-plugin.md @@ -14,9 +14,9 @@ flow is verified end-to-end against a real OpenCode server. agent roles, default acceptance criteria) before `graph.Validate`. - `POST /api/runs` — start a run from a graph; run loops live on the daemon context (not request context — bug found and fixed) and - persist via SQLite. Accepts `autoApproveGates` (stored on the run and - exposed by `GET /api/runs/{id}`); gates remain explicit, and the flag - authorizes the orchestrator to call the normal approval endpoint. + persist via SQLite. An operator-only `autoApproveGates` option is stored + on the run and exposed by `GET /api/runs/{id}`; gates remain explicit, + and model agents cannot mint this authority themselves. - `GET /api/runs`, `GET /api/runs/{id}` — follow execution (states, attempts, event log). - `GET /api/runs/{id}/watch` — bounded JSON long-poll of run deltas from a @@ -38,17 +38,17 @@ flow is verified end-to-end against a real OpenCode server. `opencode serve`, wires store + adapter + worktrees + verifier. - `.opencode/tools/corral.ts` — the thin plugin: `corral_plan`, `corral_start` (accepts the raw graph *or* the full `corral_plan` output, - unwrapping a leading `{"graph": ...}` wrapper, plus an optional - `autoApproveGates` flag), `corral_status`, `corral_watch` (blocks on the + unwrapping a leading `{"graph": ...}` wrapper), `corral_status`, + `corral_watch` (blocks on the daemon long-poll endpoint and returns the first run delta — node transition, gate awaiting approval, or run done — or times out), `corral_approve`, `corral_reject`, `corral_cancel`, `corral_retry`, `corral_steer`, calling the daemon and mapping the session agent (`corral-*`) to a role. - `example/opencode.json` — agent role configuration using OpenCode's - per-agent permissions: orchestrator (deny edit/bash, allow corral_*), - planner (read-only + corral_plan), worker (ask edits/bash), reviewer - (deny all tools; evaluates supplied evidence only), merger (deny - edit; bash allow only `git status/log/diff`, ask merge/checkout/branch). + fail-closed per-agent permissions: every role starts with wildcard deny; + orchestrator allows only corral_*, planner only corral_plan, worker allows + read/glob and asks for edits/bash, reviewer denies all tools, and merger + allows only restricted git commands. ## Acceptance verification diff --git a/example/opencode.json b/example/opencode.json index 4237527..235e39f 100644 --- a/example/opencode.json +++ b/example/opencode.json @@ -6,8 +6,7 @@ "mode": "primary", "prompt": "You are the corral orchestrator. You start runs, watch them, approve or reject gates, and steer workers. You never edit files and never run bash commands.\n\nRun loop: start a run from an approved graph with corral_start, then repeatedly call corral_watch with the runID and the previous response's `since` cursor (use a timeout around 60) to follow it. Report milestones to the user as nodes progress.\n\nWhen corral_watch reports gatesAwaitingApproval:\n- If the response's autoApproveGates is true, the run is pre-authorized: call corral_approve for each waiting gate and continue watching.\n- If autoApproveGates is false, you are NOT pre-authorized: never call corral_approve. Tell the user the gate awaits their approval, and keep calling corral_watch until the gate resolves, then continue driving the run.\n\nKeep watching until the response shows done: true, then summarize the outcome for the user.", "permission": { - "edit": "deny", - "bash": "deny", + "*": "deny", "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", @@ -24,8 +23,13 @@ "mode": "primary", "prompt": "You are the corral planner. Analyze the codebase read-only and produce a corral task graph JSON for the user's goal using corral_plan. Never modify files or run commands.", "permission": { - "edit": "deny", - "bash": "deny", + "*": "deny", + "read": { + "*": "allow", + ".corral": "deny", + ".corral/*": "deny" + }, + "glob": "allow", "corral_plan": "allow" } }, @@ -34,6 +38,13 @@ "mode": "subagent", "prompt": "You are a corral worker. Implement the objective precisely, staying within your declared write scope. Write clean, minimal changes and verify your output.", "permission": { + "*": "deny", + "read": { + "*": "allow", + ".corral": "deny", + ".corral/*": "deny" + }, + "glob": "allow", "edit": "ask", "bash": "ask" } @@ -51,7 +62,7 @@ "mode": "subagent", "prompt": "You are the corral merger. You only perform git operations to merge accepted branches. Never edit source files.", "permission": { - "edit": "deny", + "*": "deny", "bash": { "*": "deny", "git status*": "allow", diff --git a/expense-report-workflow.excalidraw b/expense-report-workflow.excalidraw new file mode 100644 index 0000000..86d2a39 --- /dev/null +++ b/expense-report-workflow.excalidraw @@ -0,0 +1,2617 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "title", + "type": "text", + "x": 60, + "y": 40, + "width": 700, + "height": 36, + "text": "Agent-Prepared Monthly Expense Report", + "originalText": "Agent-Prepared Monthly Expense Report", + "fontSize": 28, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#111827", + "strokeColor": "#111827", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600001, + "version": 1, + "versionNonce": 600001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "subtitle", + "type": "text", + "x": 60, + "y": 86, + "width": 1120, + "height": 22, + "text": "Every report line stays linked to its submission, card transaction, and receipt — from ingestion to approval.", + "originalText": "Every report line stays linked to its submission, card transaction, and receipt — from ingestion to approval.", + "fontSize": 16, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600002, + "version": 1, + "versionNonce": 600002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r1_region", + "type": "rectangle", + "x": 40, + "y": 150, + "width": 390, + "height": 720, + "text": "", + "originalText": "", + "strokeColor": "#e5e7eb", + "backgroundColor": "#f9fafb", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100001, + "version": 1, + "versionNonce": 100001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r2_region", + "type": "rectangle", + "x": 470, + "y": 150, + "width": 400, + "height": 720, + "text": "", + "originalText": "", + "strokeColor": "#e5e7eb", + "backgroundColor": "#f9fafb", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200001, + "version": 1, + "versionNonce": 200001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r1_label", + "type": "text", + "x": 60, + "y": 163, + "width": 140, + "height": 24, + "text": "1 · SOURCES", + "originalText": "1 · SOURCES", + "fontSize": 18, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100002, + "version": 1, + "versionNonce": 100002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r1_chip", + "type": "rectangle", + "x": 354, + "y": 165, + "width": 64, + "height": 24, + "text": "CONFIG", + "originalText": "CONFIG", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "#f3f4f6", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100003, + "version": 1, + "versionNonce": 100003, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_a_ellipse", + "type": "ellipse", + "x": 60, + "y": 200, + "width": 180, + "height": 70, + "text": "Online form\nexpenses → JSON API", + "originalText": "Online form\nexpenses → JSON API", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#3b82f6", + "backgroundColor": "#dbeafe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100004, + "version": 1, + "versionNonce": 100004, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_a_json", + "type": "rectangle", + "x": 60, + "y": 290, + "width": 240, + "height": 140, + "text": "{\n \"employee\": \"A. Silva\",\n \"merchant\": \"Uber\",\n \"amount\": 82.40,\n \"date\": \"2026-08-03\"\n}", + "originalText": "{\n \"employee\": \"A. Silva\",\n \"merchant\": \"Uber\",\n \"amount\": 82.40,\n \"date\": \"2026-08-03\"\n}", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#cdd6f4", + "strokeColor": "#313244", + "backgroundColor": "#1e1e2e", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100005, + "version": 1, + "versionNonce": 100005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_b_ellipse", + "type": "ellipse", + "x": 60, + "y": 460, + "width": 180, + "height": 70, + "text": "Corporate card\nCSV upload (Finance)", + "originalText": "Corporate card\nCSV upload (Finance)", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#3b82f6", + "backgroundColor": "#dbeafe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100006, + "version": 1, + "versionNonce": 100006, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_b_csv", + "type": "rectangle", + "x": 60, + "y": 552, + "width": 240, + "height": 84, + "text": "date,merchant,amount\n2026-08-03,Uber,82.40\n2026-08-04,Delta,412.00", + "originalText": "date,merchant,amount\n2026-08-03,Uber,82.40\n2026-08-04,Delta,412.00", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#cdd6f4", + "strokeColor": "#313244", + "backgroundColor": "#1e1e2e", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100007, + "version": 1, + "versionNonce": 100007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_c_ellipse", + "type": "ellipse", + "x": 60, + "y": 690, + "width": 180, + "height": 70, + "text": "Receipts attached\nPDF · images", + "originalText": "Receipts attached\nPDF · images", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#3b82f6", + "backgroundColor": "#dbeafe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100008, + "version": 1, + "versionNonce": 100008, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "src_c_file", + "type": "rectangle", + "x": 60, + "y": 780, + "width": 240, + "height": 64, + "text": "receipt_2026-08-03.pdf\n→ OCR: \"Uber · $82.40\"", + "originalText": "receipt_2026-08-03.pdf\n→ OCR: \"Uber · $82.40\"", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#cdd6f4", + "strokeColor": "#313244", + "backgroundColor": "#1e1e2e", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100009, + "version": 1, + "versionNonce": 100009, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r2_label", + "type": "text", + "x": 490, + "y": 163, + "width": 250, + "height": 24, + "text": "2 · NORMALIZE & LINK", + "originalText": "2 · NORMALIZE & LINK", + "fontSize": 18, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200002, + "version": 1, + "versionNonce": 200002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r2_chip", + "type": "rectangle", + "x": 794, + "y": 165, + "width": 64, + "height": 24, + "text": "CONFIG", + "originalText": "CONFIG", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "#f3f4f6", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200003, + "version": 1, + "versionNonce": 200003, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "store_rect", + "type": "rectangle", + "x": 530, + "y": 320, + "width": 280, + "height": 100, + "text": "Linked expense records\n(one row per expense)", + "originalText": "Linked expense records\n(one row per expense)", + "fontSize": 16, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#0ea5e9", + "backgroundColor": "#e0f2fe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200004, + "version": 1, + "versionNonce": 200004, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "store_json", + "type": "rectangle", + "x": 510, + "y": 470, + "width": 300, + "height": 140, + "text": "{\n \"expense_id\": \"EXP-1042\",\n \"submission_id\": \"SUB-8810\",\n \"card_txn_id\": \"TXN-55217\",\n \"receipt_id\": \"RCPT-3091\"\n}", + "originalText": "{\n \"expense_id\": \"EXP-1042\",\n \"submission_id\": \"SUB-8810\",\n \"card_txn_id\": \"TXN-55217\",\n \"receipt_id\": \"RCPT-3091\"\n}", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#cdd6f4", + "strokeColor": "#313244", + "backgroundColor": "#1e1e2e", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200005, + "version": 1, + "versionNonce": 200005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "store_caption", + "type": "text", + "x": 510, + "y": 630, + "width": 355, + "height": 18, + "text": "Provenance preserved through every later step", + "originalText": "Provenance preserved through every later step", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200006, + "version": 1, + "versionNonce": 200006, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_a_store", + "type": "arrow", + "x": 246, + "y": 238, + "width": 278, + "height": 117, + "points": [[0, 0], [278, 117]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100010, + "version": 1, + "versionNonce": 100010, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_b_store", + "type": "arrow", + "x": 246, + "y": 495, + "width": 278, + "height": 125, + "points": [[0, 0], [278, -125]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100011, + "version": 1, + "versionNonce": 100011, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_c_store", + "type": "arrow", + "x": 246, + "y": 725, + "width": 278, + "height": 340, + "points": [[0, 0], [278, -340]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 100012, + "version": 1, + "versionNonce": 100012, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r3_region", + "type": "rectangle", + "x": 910, + "y": 150, + "width": 420, + "height": 720, + "text": "", + "originalText": "", + "strokeColor": "#e5e7eb", + "backgroundColor": "#f9fafb", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300001, + "version": 1, + "versionNonce": 300001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r4_region", + "type": "rectangle", + "x": 1370, + "y": 150, + "width": 260, + "height": 720, + "text": "", + "originalText": "", + "strokeColor": "#e5e7eb", + "backgroundColor": "#f9fafb", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400001, + "version": 1, + "versionNonce": 400001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r3_label", + "type": "text", + "x": 930, + "y": 163, + "width": 370, + "height": 24, + "text": "3 · AGENT: MATCH · VERIFY · FLAG", + "originalText": "3 · AGENT: MATCH · VERIFY · FLAG", + "fontSize": 18, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300002, + "version": 1, + "versionNonce": 300002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r3_chip", + "type": "rectangle", + "x": 930, + "y": 193, + "width": 60, + "height": 24, + "text": "SCRIPT", + "originalText": "SCRIPT", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#8b5cf6", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300003, + "version": 1, + "versionNonce": 300003, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "match_rect", + "type": "rectangle", + "x": 970, + "y": 230, + "width": 300, + "height": 70, + "text": "Match expenses ↔ card txns\nby amount · date · merchant", + "originalText": "Match expenses ↔ card txns\nby amount · date · merchant", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300004, + "version": 1, + "versionNonce": 300004, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_m_v", + "type": "arrow", + "x": 1120, + "y": 306, + "width": 0, + "height": 38, + "points": [[0, 0], [0, 38]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300005, + "version": 1, + "versionNonce": 300005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "verify_rect", + "type": "rectangle", + "x": 970, + "y": 350, + "width": 300, + "height": 70, + "text": "Extract receipt fields (OCR)\n& verify against expense", + "originalText": "Extract receipt fields (OCR)\n& verify against expense", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300006, + "version": 1, + "versionNonce": 300006, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_v_d", + "type": "arrow", + "x": 1120, + "y": 426, + "width": 0, + "height": 38, + "points": [[0, 0], [0, 38]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300007, + "version": 1, + "versionNonce": 300007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "decide_diamond", + "type": "diamond", + "x": 990, + "y": 470, + "width": 260, + "height": 100, + "text": "Complete &\nconsistent?", + "originalText": "Complete &\nconsistent?", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#eab308", + "backgroundColor": "#fef9c3", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300008, + "version": 1, + "versionNonce": 300008, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "no_label", + "type": "text", + "x": 1132, + "y": 605, + "width": 25, + "height": 18, + "text": "no", + "originalText": "no", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#ef4444", + "strokeColor": "#ef4444", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300009, + "version": 1, + "versionNonce": 300009, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_no_exc", + "type": "arrow", + "x": 1120, + "y": 576, + "width": 0, + "height": 78, + "points": [[0, 0], [0, 78]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#ef4444", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300010, + "version": 1, + "versionNonce": 300010, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "exc_rect", + "type": "rectangle", + "x": 970, + "y": 660, + "width": 300, + "height": 80, + "text": "Exception task — employee\nsupplies missing info", + "originalText": "Exception task — employee\nsupplies missing info", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#f97316", + "backgroundColor": "#ffedd5", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300011, + "version": 1, + "versionNonce": 300011, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "store_idmap", + "type": "text", + "x": 500, + "y": 780, + "width": 350, + "height": 40, + "text": "submission_id → form record · card_txn_id → CSV row\nreceipt_id → attached receipt file", + "originalText": "submission_id → form record · card_txn_id → CSV row\nreceipt_id → attached receipt file", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 200007, + "version": 1, + "versionNonce": 200007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_loop", + "type": "arrow", + "x": 964, + "y": 700, + "width": 514, + "height": 330, + "points": [[0, 0], [-514, 0], [-514, -330], [-440, -330]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300012, + "version": 1, + "versionNonce": 300012, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "loop_label", + "type": "text", + "x": 560, + "y": 708, + "width": 280, + "height": 16, + "text": "correction → re-matched & re-verified", + "originalText": "correction → re-matched & re-verified", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 300013, + "version": 1, + "versionNonce": 300013, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r4_label", + "type": "text", + "x": 1390, + "y": 163, + "width": 150, + "height": 24, + "text": "4 · ASSEMBLE", + "originalText": "4 · ASSEMBLE", + "fontSize": 18, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400002, + "version": 1, + "versionNonce": 400002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r4_chip", + "type": "rectangle", + "x": 1548, + "y": 165, + "width": 60, + "height": 24, + "text": "SCRIPT", + "originalText": "SCRIPT", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#8b5cf6", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400003, + "version": 1, + "versionNonce": 400003, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "asm_rect", + "type": "rectangle", + "x": 1410, + "y": 475, + "width": 190, + "height": 90, + "text": "Assemble monthly\nreport package", + "originalText": "Assemble monthly\nreport package", + "fontSize": 16, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400004, + "version": 1, + "versionNonce": 400004, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "asm_caption", + "type": "text", + "x": 1395, + "y": 581, + "width": 230, + "height": 36, + "text": "Script: join linked records\ninto report lines", + "originalText": "Script: join linked records\ninto report lines", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400005, + "version": 1, + "versionNonce": 400005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "yes_label", + "type": "text", + "x": 1294, + "y": 490, + "width": 30, + "height": 18, + "text": "yes", + "originalText": "yes", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#22c55e", + "strokeColor": "#22c55e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400006, + "version": 1, + "versionNonce": 400006, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_yes_asm", + "type": "arrow", + "x": 1256, + "y": 520, + "width": 148, + "height": 0, + "points": [[0, 0], [148, 0]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400007, + "version": 1, + "versionNonce": 400007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_asm_mock", + "type": "arrow", + "x": 1606, + "y": 520, + "width": 88, + "height": 190, + "points": [[0, 0], [88, -190]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 400008, + "version": 1, + "versionNonce": 400008, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r5_region", + "type": "rectangle", + "x": 1670, + "y": 150, + "width": 480, + "height": 720, + "text": "", + "originalText": "", + "strokeColor": "#e5e7eb", + "backgroundColor": "#f9fafb", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500001, + "version": 1, + "versionNonce": 500001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r5_label", + "type": "text", + "x": 1690, + "y": 163, + "width": 140, + "height": 24, + "text": "5 · APPROVE", + "originalText": "5 · APPROVE", + "fontSize": 18, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500002, + "version": 1, + "versionNonce": 500002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r5_chip_config", + "type": "rectangle", + "x": 1970, + "y": 165, + "width": 64, + "height": 24, + "text": "CONFIG", + "originalText": "CONFIG", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "#f3f4f6", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500003, + "version": 1, + "versionNonce": 500003, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "r5_chip_core", + "type": "rectangle", + "x": 2042, + "y": 165, + "width": 84, + "height": 24, + "text": "CORE DEV", + "originalText": "CORE DEV", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#ef4444", + "strokeColor": "#ef4444", + "backgroundColor": "#fee2e2", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500004, + "version": 1, + "versionNonce": 500004, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_rect", + "type": "rectangle", + "x": 1700, + "y": 205, + "width": 430, + "height": 250, + "text": "", + "originalText": "", + "strokeColor": "#d1d5db", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500005, + "version": 1, + "versionNonce": 500005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_title", + "type": "text", + "x": 1716, + "y": 218, + "width": 350, + "height": 20, + "text": "Approve — August 2026 expense report", + "originalText": "Approve — August 2026 expense report", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#111827", + "strokeColor": "#111827", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500006, + "version": 1, + "versionNonce": 500006, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_h1", + "type": "text", + "x": 1716, + "y": 250, + "width": 80, + "height": 14, + "text": "EXPENSE", + "originalText": "EXPENSE", + "fontSize": 11, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500007, + "version": 1, + "versionNonce": 500007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_h2", + "type": "text", + "x": 1880, + "y": 250, + "width": 160, + "height": 14, + "text": "LINKED SOURCE RECORDS", + "originalText": "LINKED SOURCE RECORDS", + "fontSize": 11, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500008, + "version": 1, + "versionNonce": 500008, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_div1", + "type": "line", + "x": 1716, + "y": 270, + "width": 398, + "height": 0, + "points": [[0, 0], [398, 0]], + "strokeColor": "#e5e7eb", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500009, + "version": 1, + "versionNonce": 500009, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r1a", + "type": "text", + "x": 1716, + "y": 278, + "width": 175, + "height": 18, + "text": "Uber · Aug 3 · $82.40", + "originalText": "Uber · Aug 3 · $82.40", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#111827", + "strokeColor": "#111827", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500010, + "version": 1, + "versionNonce": 500010, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r1b", + "type": "text", + "x": 1880, + "y": 278, + "width": 240, + "height": 16, + "text": "SUB-8810 · TXN-55217 · RCPT-3091", + "originalText": "SUB-8810 · TXN-55217 · RCPT-3091", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#0ea5e9", + "strokeColor": "#0ea5e9", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500011, + "version": 1, + "versionNonce": 500011, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_div2", + "type": "line", + "x": 1716, + "y": 300, + "width": 398, + "height": 0, + "points": [[0, 0], [398, 0]], + "strokeColor": "#e5e7eb", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500012, + "version": 1, + "versionNonce": 500012, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r2a", + "type": "text", + "x": 1716, + "y": 308, + "width": 190, + "height": 18, + "text": "Delta · Aug 4 · $412.00", + "originalText": "Delta · Aug 4 · $412.00", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#111827", + "strokeColor": "#111827", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500013, + "version": 1, + "versionNonce": 500013, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r2b", + "type": "text", + "x": 1880, + "y": 308, + "width": 240, + "height": 16, + "text": "SUB-8811 · TXN-55231 · RCPT-3092", + "originalText": "SUB-8811 · TXN-55231 · RCPT-3092", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#0ea5e9", + "strokeColor": "#0ea5e9", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500014, + "version": 1, + "versionNonce": 500014, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_div3", + "type": "line", + "x": 1716, + "y": 330, + "width": 398, + "height": 0, + "points": [[0, 0], [398, 0]], + "strokeColor": "#e5e7eb", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500015, + "version": 1, + "versionNonce": 500015, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r3a", + "type": "text", + "x": 1716, + "y": 338, + "width": 190, + "height": 18, + "text": "Hilton · Aug 5 · $268.90", + "originalText": "Hilton · Aug 5 · $268.90", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#111827", + "strokeColor": "#111827", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500016, + "version": 1, + "versionNonce": 500016, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_r3b", + "type": "text", + "x": 1880, + "y": 338, + "width": 240, + "height": 16, + "text": "SUB-8812 · TXN-55245 · RCPT-3093", + "originalText": "SUB-8812 · TXN-55245 · RCPT-3093", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#0ea5e9", + "strokeColor": "#0ea5e9", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500017, + "version": 1, + "versionNonce": 500017, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "btn_approve", + "type": "rectangle", + "x": 1716, + "y": 392, + "width": 100, + "height": 34, + "text": "Approve", + "originalText": "Approve", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#22c55e", + "backgroundColor": "#dcfce7", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500018, + "version": 1, + "versionNonce": 500018, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "btn_reject", + "type": "rectangle", + "x": 1828, + "y": 392, + "width": 100, + "height": 34, + "text": "Reject", + "originalText": "Reject", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#6b7280", + "strokeColor": "#d1d5db", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500019, + "version": 1, + "versionNonce": 500019, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "mock_caption", + "type": "text", + "x": 1700, + "y": 468, + "width": 445, + "height": 18, + "text": "Manager approves in-platform — human task, audit-logged", + "originalText": "Manager approves in-platform — human task, audit-logged", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500020, + "version": 1, + "versionNonce": 500020, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "gap_box", + "type": "rectangle", + "x": 1700, + "y": 520, + "width": 430, + "height": 205, + "text": "", + "originalText": "", + "strokeColor": "#ef4444", + "backgroundColor": "#fee2e2", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500021, + "version": 1, + "versionNonce": 500021, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "gap_title", + "type": "text", + "x": 1716, + "y": 532, + "width": 360, + "height": 20, + "text": "PLATFORM GAP → CORE PLATFORM DEVELOPMENT", + "originalText": "PLATFORM GAP → CORE PLATFORM DEVELOPMENT", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#ef4444", + "strokeColor": "#ef4444", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500022, + "version": 1, + "versionNonce": 500022, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "gap_body", + "type": "text", + "x": 1716, + "y": 558, + "width": 400, + "height": 158, + "text": "Requirement: render record-level provenance in the\napproval screen — each report line shows its linked\nsubmission, card transaction, and receipt as links\nto the original records.\nAcceptance criteria:\n• 100% of report lines display all three links\n• Links open the original submission, txn, receipt\n• Approval writes the linked IDs to the audit log", + "originalText": "Requirement: render record-level provenance in the\napproval screen — each report line shows its linked\nsubmission, card transaction, and receipt as links\nto the original records.\nAcceptance criteria:\n• 100% of report lines display all three links\n• Links open the original submission, txn, receipt\n• Approval writes the linked IDs to the audit log", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#374151", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500023, + "version": 1, + "versionNonce": 500023, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "out_ellipse", + "type": "ellipse", + "x": 2210, + "y": 320, + "width": 240, + "height": 80, + "text": "Approved monthly\nexpense report", + "originalText": "Approved monthly\nexpense report", + "fontSize": 16, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#22c55e", + "backgroundColor": "#dcfce7", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500024, + "version": 1, + "versionNonce": 500024, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "arr_mock_out", + "type": "arrow", + "x": 2136, + "y": 340, + "width": 68, + "height": 18, + "points": [[0, 0], [68, 18]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 500025, + "version": 1, + "versionNonce": 500025, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_bar", + "type": "rectangle", + "x": 60, + "y": 955, + "width": 2090, + "height": 55, + "text": "AUDIT LOG — every ingestion, match, flag, correction, and approval recorded with actor + timestamp", + "originalText": "AUDIT LOG — every ingestion, match, flag, correction, and approval recorded with actor + timestamp", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#111827", + "strokeColor": "#0ea5e9", + "backgroundColor": "#e0f2fe", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600010, + "version": 1, + "versionNonce": 600010, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_arr_1", + "type": "arrow", + "x": 235, + "y": 876, + "width": 0, + "height": 73, + "points": [[0, 0], [0, 73]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600011, + "version": 1, + "versionNonce": 600011, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_arr_2", + "type": "arrow", + "x": 670, + "y": 876, + "width": 0, + "height": 73, + "points": [[0, 0], [0, 73]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600012, + "version": 1, + "versionNonce": 600012, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_arr_3", + "type": "arrow", + "x": 1120, + "y": 876, + "width": 0, + "height": 73, + "points": [[0, 0], [0, 73]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600013, + "version": 1, + "versionNonce": 600013, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_arr_4", + "type": "arrow", + "x": 1500, + "y": 876, + "width": 0, + "height": 73, + "points": [[0, 0], [0, 73]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600014, + "version": 1, + "versionNonce": 600014, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "audit_arr_5", + "type": "arrow", + "x": 1910, + "y": 876, + "width": 0, + "height": 73, + "points": [[0, 0], [0, 73]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600015, + "version": 1, + "versionNonce": 600015, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_title", + "type": "text", + "x": 60, + "y": 1035, + "width": 220, + "height": 18, + "text": "CLASSIFICATION KEY", + "originalText": "CLASSIFICATION KEY", + "fontSize": 13, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#9ca3af", + "strokeColor": "#9ca3af", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600016, + "version": 1, + "versionNonce": 600016, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_chip1", + "type": "rectangle", + "x": 60, + "y": 1065, + "width": 64, + "height": 24, + "text": "CONFIG", + "originalText": "CONFIG", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "#f3f4f6", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600017, + "version": 1, + "versionNonce": 600017, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_lab1", + "type": "text", + "x": 134, + "y": 1068, + "width": 195, + "height": 20, + "text": "Platform configuration", + "originalText": "Platform configuration", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600018, + "version": 1, + "versionNonce": 600018, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_chip2", + "type": "rectangle", + "x": 349, + "y": 1065, + "width": 60, + "height": 24, + "text": "SCRIPT", + "originalText": "SCRIPT", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#8b5cf6", + "strokeColor": "#8b5cf6", + "backgroundColor": "#ede9fe", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600019, + "version": 1, + "versionNonce": 600019, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_lab2", + "type": "text", + "x": 419, + "y": 1068, + "width": 300, + "height": 20, + "text": "Lightweight scripting (Python / JS)", + "originalText": "Lightweight scripting (Python / JS)", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600020, + "version": 1, + "versionNonce": 600020, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_chip3", + "type": "rectangle", + "x": 739, + "y": 1065, + "width": 84, + "height": 24, + "text": "CORE DEV", + "originalText": "CORE DEV", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#ef4444", + "strokeColor": "#ef4444", + "backgroundColor": "#fee2e2", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600021, + "version": 1, + "versionNonce": 600021, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_lab3", + "type": "text", + "x": 833, + "y": 1068, + "width": 220, + "height": 20, + "text": "Core platform development", + "originalText": "Core platform development", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600022, + "version": 1, + "versionNonce": 600022, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_chip4", + "type": "rectangle", + "x": 1073, + "y": 1065, + "width": 60, + "height": 24, + "text": "HUMAN", + "originalText": "HUMAN", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#f97316", + "strokeColor": "#f97316", + "backgroundColor": "#ffedd5", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600023, + "version": 1, + "versionNonce": 600023, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_lab4", + "type": "text", + "x": 1143, + "y": 1068, + "width": 90, + "height": 20, + "text": "Human step", + "originalText": "Human step", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600024, + "version": 1, + "versionNonce": 600024, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_chip5", + "type": "rectangle", + "x": 1253, + "y": 1065, + "width": 54, + "height": 24, + "text": "DATA", + "originalText": "DATA", + "fontSize": 12, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "middle", + "color": "#0ea5e9", + "strokeColor": "#0ea5e9", + "backgroundColor": "#e0f2fe", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600025, + "version": 1, + "versionNonce": 600025, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + }, + { + "id": "leg_lab5", + "type": "text", + "x": 1317, + "y": 1068, + "width": 90, + "height": 20, + "text": "Data store", + "originalText": "Data store", + "fontSize": 14, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "color": "#6b7280", + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "angle": 0, + "seed": 600026, + "version": 1, + "versionNonce": 600026, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "groupIds": [] + } + ], + "appState": { + "viewBackgroundColor": "#ffffff", + "gridSize": 20 + }, + "files": {} +} diff --git a/expense-report-workflow.png b/expense-report-workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..f998d4149ed674ac3e842ec1685d97d8188fe006 GIT binary patch literal 435173 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%0DC8E|^)5#+$2^z&ChyX03{Ms-3n_ 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

WdnTvmH|3daz**SPxD+>$*#Z&Sjt*NXv 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

KaUhBfGxK! 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 = { graph } - if (args.autoApproveGates !== undefined) body.autoApproveGates = args.autoApproveGates - return call("/api/runs", body, roleFor(context.agent)) + return call("/api/runs", { graph }, roleFor(context.agent)) }, }) diff --git a/internal/assets/opencode.json b/internal/assets/opencode.json index 4237527..235e39f 100644 --- a/internal/assets/opencode.json +++ b/internal/assets/opencode.json @@ -6,8 +6,7 @@ "mode": "primary", "prompt": "You are the corral orchestrator. You start runs, watch them, approve or reject gates, and steer workers. You never edit files and never run bash commands.\n\nRun loop: start a run from an approved graph with corral_start, then repeatedly call corral_watch with the runID and the previous response's `since` cursor (use a timeout around 60) to follow it. Report milestones to the user as nodes progress.\n\nWhen corral_watch reports gatesAwaitingApproval:\n- If the response's autoApproveGates is true, the run is pre-authorized: call corral_approve for each waiting gate and continue watching.\n- If autoApproveGates is false, you are NOT pre-authorized: never call corral_approve. Tell the user the gate awaits their approval, and keep calling corral_watch until the gate resolves, then continue driving the run.\n\nKeep watching until the response shows done: true, then summarize the outcome for the user.", "permission": { - "edit": "deny", - "bash": "deny", + "*": "deny", "corral_plan": "allow", "corral_start": "allow", "corral_status": "allow", @@ -24,8 +23,13 @@ "mode": "primary", "prompt": "You are the corral planner. Analyze the codebase read-only and produce a corral task graph JSON for the user's goal using corral_plan. Never modify files or run commands.", "permission": { - "edit": "deny", - "bash": "deny", + "*": "deny", + "read": { + "*": "allow", + ".corral": "deny", + ".corral/*": "deny" + }, + "glob": "allow", "corral_plan": "allow" } }, @@ -34,6 +38,13 @@ "mode": "subagent", "prompt": "You are a corral worker. Implement the objective precisely, staying within your declared write scope. Write clean, minimal changes and verify your output.", "permission": { + "*": "deny", + "read": { + "*": "allow", + ".corral": "deny", + ".corral/*": "deny" + }, + "glob": "allow", "edit": "ask", "bash": "ask" } @@ -51,7 +62,7 @@ "mode": "subagent", "prompt": "You are the corral merger. You only perform git operations to merge accepted branches. Never edit source files.", "permission": { - "edit": "deny", + "*": "deny", "bash": { "*": "deny", "git status*": "allow", diff --git a/internal/claudeadapter/adapter.go b/internal/claudeadapter/adapter.go index fa24f58..23ee928 100644 --- a/internal/claudeadapter/adapter.go +++ b/internal/claudeadapter/adapter.go @@ -104,9 +104,11 @@ type attempt struct { exitedCh chan processExit // stream reader -> watcher after stdout is drained cancel context.CancelFunc - mu sync.Mutex - transcript []adapter.Message - permission string // pending permission request id ("" = none); at most one request is admitted + mu sync.Mutex + transcript []adapter.Message + permission string // pending permission request id ("" = none); at most one request is admitted + permissionTool string + permissionInput string } func New(opts Options) *Driver { @@ -942,6 +944,16 @@ func allowedTools(a adapter.Attempt) []string { return tools } +func (s *session) PendingPermissionDetails(ctx context.Context) (adapter.PermissionDetails, bool, error) { + id, pending, err := s.PendingPermission(ctx) + if err != nil || !pending { + return adapter.PermissionDetails{}, false, err + } + s.at.mu.Lock() + defer s.at.mu.Unlock() + return adapter.PermissionDetails{ID: id, Tool: s.at.permissionTool, Input: s.at.permissionInput}, true, nil +} + // newUUID returns a v4 UUID used as the claude --session-id. func newUUID() string { var b [16]byte diff --git a/internal/claudeadapter/adapter_test.go b/internal/claudeadapter/adapter_test.go index 8018eb5..ed67ee1 100644 --- a/internal/claudeadapter/adapter_test.go +++ b/internal/claudeadapter/adapter_test.go @@ -1144,6 +1144,10 @@ func TestPermissionBroker(t *testing.T) { // The scheduler's view: the permission is pending and the session is // permission-capable. waitPending(t, ps, "req-1") + info, ok, err := sess.(adapter.PermissionInfo).PendingPermissionDetails(context.Background()) + if err != nil || !ok || info.Tool != "Write" || !strings.Contains(info.Input, "src/alpha.txt") { + t.Fatalf("permission details = %+v, %v, %v", info, ok, err) + } // Claude may issue tool calls concurrently. The adapter exposes one // permission at a time, so a second request is denied immediately instead diff --git a/internal/claudeadapter/permission.go b/internal/claudeadapter/permission.go index fc7b564..c95bdbb 100644 --- a/internal/claudeadapter/permission.go +++ b/internal/claudeadapter/permission.go @@ -227,6 +227,8 @@ func (b *permissionBroker) handle(conn net.Conn) { } b.pending[key] = ch at.permission = req.RequestID + at.permissionTool = sanitizePermissionText(req.ToolName, 200) + at.permissionInput = sanitizePermissionText(string(req.ToolInput), 2000) at.mu.Unlock() b.mu.Unlock() @@ -246,11 +248,26 @@ func (b *permissionBroker) handle(conn net.Conn) { at.mu.Lock() if at.permission == req.RequestID { at.permission = "" + at.permissionTool = "" + at.permissionInput = "" } at.mu.Unlock() _ = enc.Encode(reply) } +func sanitizePermissionText(s string, max int) string { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\t' || (r >= 0x20 && r != 0x7f) { + return r + } + return -1 + }, s) + if len(s) > max { + return s[:max] + "..." + } + return s +} + // respond claims and resolves the helper waiting on requestID. Missing and // duplicate responses are rejected instead of being silently accepted. func (b *permissionBroker) respond(ctx context.Context, at *attempt, requestID string, allow bool) error { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 7817ad5..826cb2d 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -226,6 +226,13 @@ func (d *Daemon) handleCreateRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "graph required", http.StatusBadRequest) return } + if req.AutoApproveGates { + role, _ := parseRole(r.Header.Get("X-Corral-Role")) + if role != RoleOperator { + http.Error(w, "only an operator may pre-authorize human gates", http.StatusForbidden) + return + } + } ctx := r.Context() runID := "run_" + randID(6) h, err := d.sched.Create(ctx, runID, req.Graph, sched.CreateOptions{AutoApproveGates: req.AutoApproveGates}) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index dd40ae0..5d3b319 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -143,6 +143,11 @@ func TestRoleEnforcement(t *testing.T) { if code, _ := a.do("orchestrator", http.MethodPost, "/api/runs", map[string]any{"graph": g}); code != http.StatusCreated { t.Fatalf("orchestrator create run: %d, want 201", code) } + if code, body := a.do("orchestrator", http.MethodPost, "/api/runs", map[string]any{ + "graph": g, "autoApproveGates": true, + }); code != http.StatusForbidden || !strings.Contains(body, "only an operator") { + t.Fatalf("orchestrator self-authorization: %d %s, want 403", code, body) + } // Workers may not approve; operators may. (Run id from the created run.) if code, _ := a.do("worker", http.MethodPost, "/api/runs/whatever/approve", map[string]any{"nodeID": "w1"}); code != http.StatusForbidden { t.Fatalf("worker approve: %d, want 403", code) @@ -368,6 +373,7 @@ func TestCancelAndRetryThroughAPI(t *testing.T) { // Failed node retried through the API. bad := workerNode("bad", "x.txt", "X") + bad.WriteScope = []string{"x.txt", "missing.txt"} bad.Verification = &graph.Verification{Kind: "command", Command: []string{"test", "-f", "missing.txt"}} bad.RetryPolicy = graph.RetryPolicy{MaxRetries: 0, Backoff: tick} drv.AppendScript("bad", sched.Script{Delay: 100 * time.Millisecond, Write: map[string]string{"x.txt": "X"}}) diff --git a/internal/daemon/planner.go b/internal/daemon/planner.go index f306d51..8499ac6 100644 --- a/internal/daemon/planner.go +++ b/internal/daemon/planner.go @@ -32,17 +32,9 @@ func NewOpenCodePlanner(oc *ocx.Client, model string, timeout time.Duration) *Op // anything that executes or writes is disabled. This prevents tool loops // that stall planning. var planTools = map[string]bool{ - "bash": false, - "edit": false, - "write": false, - "apply_patch": false, - "webfetch": false, - "websearch": false, - "task": false, - "todowrite": false, - "question": false, - "skill": false, - "lsp": false, + "*": false, + "read": true, + "glob": true, } var errNoGraph = errors.New("planner produced no valid graph") diff --git a/internal/daemon/planner_test.go b/internal/daemon/planner_test.go index 9a81bbf..aa60b01 100644 --- a/internal/daemon/planner_test.go +++ b/internal/daemon/planner_test.go @@ -70,6 +70,12 @@ func TestParseGraphFromResponse(t *testing.T) { } } +func TestPlannerToolsFailClosed(t *testing.T) { + if planTools["*"] || !planTools["read"] || !planTools["glob"] || len(planTools) != 3 { + t.Fatalf("planner tools = %#v, want wildcard deny plus read/glob", planTools) + } +} + func TestFindJSONEnd(t *testing.T) { s := `{"a": {"b": [1, {"c": "} { "}]}} tail` if end := findJSONEnd(s, 0); end != len(s)-5 { diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 599198c..5145419 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -75,6 +75,28 @@ func TestValidateRejectsDuplicateAndEmptyIDs(t *testing.T) { } } +func TestValidateRejectsUnknownAgentRoleAndUnsafeScope(t *testing.T) { + for _, tc := range []struct { + name string + role string + scope []string + }{ + {"unknown role", "admin", []string{"a.txt"}}, + {"absolute scope", "worker", []string{"/tmp/a.txt"}}, + {"parent scope", "worker", []string{"../a.txt"}}, + {"wildcard path", "worker", []string{"src/*.go"}}, + {"reviewer scope", "reviewer", []string{"a.txt"}}, + } { + t.Run(tc.name, func(t *testing.T) { + n := agent("a") + n.Role, n.WriteScope = tc.role, tc.scope + if err := Validate(&Graph{Nodes: []*Node{n}}); err == nil { + t.Fatal("unsafe agent accepted") + } + }) + } +} + func TestValidateRejectsMissingAcceptanceCriteria(t *testing.T) { n := agent("a") n.AcceptanceCriteria = nil diff --git a/internal/graph/validate.go b/internal/graph/validate.go index e914253..04425f3 100644 --- a/internal/graph/validate.go +++ b/internal/graph/validate.go @@ -2,6 +2,7 @@ package graph import ( "fmt" + "path/filepath" "sort" "strings" ) @@ -69,6 +70,19 @@ func validateNode(n *Node) error { return fmt.Errorf("objective exceeds %d chars", maxObjectiveLen) } if n.Type == NodeAgent { + switch n.Role { + case "", "worker", "reviewer": + default: + return fmt.Errorf("invalid agent role %q", n.Role) + } + if n.Role == "reviewer" && len(n.WriteScope) > 0 { + return fmt.Errorf("reviewer agent cannot declare write scope") + } + for _, scope := range n.WriteScope { + if err := validateWriteScope(scope); err != nil { + return err + } + } if len(n.AcceptanceCriteria) == 0 { return fmt.Errorf("agent node missing acceptance criteria") } @@ -122,6 +136,27 @@ func validateNode(n *Node) error { return nil } +func validateWriteScope(scope string) error { + scope = strings.TrimSpace(strings.ReplaceAll(scope, `\`, "/")) + if scope == "" { + return fmt.Errorf("empty write scope") + } + if scope == "*" { + return nil + } + if filepath.IsAbs(filepath.FromSlash(scope)) { + return fmt.Errorf("write scope %q must be relative", scope) + } + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(scope))) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return fmt.Errorf("write scope %q escapes worktree", scope) + } + if strings.ContainsAny(clean, "*?[") { + return fmt.Errorf("write scope %q contains unsupported wildcard", scope) + } + return nil +} + func nodeLabel(n *Node, i int) string { if n == nil { return fmt.Sprintf("index %d", i) diff --git a/internal/ocxadapter/adapter.go b/internal/ocxadapter/adapter.go index b9fafa8..197e37c 100644 --- a/internal/ocxadapter/adapter.go +++ b/internal/ocxadapter/adapter.go @@ -157,6 +157,13 @@ func (d *Driver) clientFor(cwd string) *ocx.Client { // Start creates an OpenCode session, sends the objective, and starts a // watcher that completes the attempt via events + polling fallback. func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, error) { + role := a.Role + if role == "" { + role = "worker" + } + if role != "worker" && role != "reviewer" { + return nil, fmt.Errorf("start OpenCode: unsupported agent role %q", a.Role) + } d.mu.Lock() if d.closed { d.mu.Unlock() @@ -252,7 +259,7 @@ func (d *Driver) Start(ctx context.Context, a adapter.Attempt) (adapter.Session, if model == "" { model = d.opts.Model } - if err := client.PromptAsync(ctx, sess.ID, prompt, model); err != nil { + if err := client.PromptAsyncAgent(ctx, sess.ID, prompt, model, "corral-"+role); err != nil { atCancel() at.cleanup() cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 7747514..e847fb5 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -429,7 +429,14 @@ func (h *RunHandle) Step(ctx context.Context) error { rec.budgetPaused = true } rec.budgeted = false - payload, _ := json.Marshal(map[string]any{"reason": "permission", "permissionID": pid}) + permission := map[string]any{"reason": "permission", "permissionID": pid} + if info, ok := rec.sess.(adapter.PermissionInfo); ok { + if detail, detailOK, detailErr := info.PendingPermissionDetails(ctx); detailErr == nil && detailOK { + permission["tool"] = detail.Tool + permission["input"] = detail.Input + } + } + payload, _ := json.Marshal(permission) if err := h.transit(ctx, id, graph.StateRunning, graph.StateBlocked, string(payload)); err != nil { return err } @@ -1233,9 +1240,29 @@ func (h *RunHandle) finishAttempt(ctx context.Context, res Result) error { return err } node := h.nodeByID(rec.nodeID) - verdict, err := h.s.ver.Verdict(ctx, node, rec.no, rec.worktree, res.Messages) - if err != nil { - verdict = Verdict{Pass: false, Feedback: "verifier error: " + err.Error()} + var verdict Verdict + if rec.worktree != "" && h.s.opts.Worktrees != nil && worktree.NodeIsWriting(string(node.Type), node.Role) { + files, scopeErr := h.s.opts.Worktrees.ChangedFiles(ctx, rec.worktree) + if scopeErr != nil { + verdict = Verdict{Pass: false, Feedback: "scope inspection failed: " + scopeErr.Error()} + } else { + var outside []string + for _, file := range files { + if !worktree.ScopeContains(node.WriteScope, file) { + outside = append(outside, file) + } + } + if len(outside) > 0 { + verdict = Verdict{Pass: false, Feedback: "attempt changed paths outside write scope: " + strings.Join(outside, ", ")} + } + } + } + if verdict.Feedback == "" { + var err error + verdict, err = h.s.ver.Verdict(ctx, node, rec.no, rec.worktree, res.Messages) + if err != nil { + verdict = Verdict{Pass: false, Feedback: "verifier error: " + err.Error()} + } } ev, _ := json.Marshal(map[string]any{"pass": verdict.Pass, "feedback": verdict.Feedback, "evidence": verdict.Evidence}) if err := h.emitEvent(ctx, store.EventVerdict, rec.nodeID, graph.State(""), graph.State(""), rec.attemptID, string(ev)); err != nil { diff --git a/internal/sched/worktree_test.go b/internal/sched/worktree_test.go index a1b176e..20b222e 100644 --- a/internal/sched/worktree_test.go +++ b/internal/sched/worktree_test.go @@ -236,6 +236,27 @@ func TestFailedWorktreeRetainedForInspection(t *testing.T) { } } +func TestOutOfScopeWorktreeChangesCannotPass(t *testing.T) { + n := workerNode("bad-scope", "allowed.txt", "ok", "allowed.txt") + n.RetryPolicy.MaxRetries = 0 + st, h, _, clk := setupIsolated(t, &graph.Graph{Nodes: []*graph.Node{n}}, map[string][]sched.Script{ + "bad-scope": {{Delay: tick, Write: map[string]string{"outside.txt": "escape"}}}, + }) + ctx := context.Background() + for i := 0; i < 50 && !h.Done(); i++ { + if err := step(h, clk, ctx); err != nil { + t.Fatal(err) + } + } + if state, _ := h.State("bad-scope"); state != graph.StateFailed { + t.Fatalf("out-of-scope state = %s, want failed", state) + } + atts := attemptsOf(t, st, "run-wt", "bad-scope") + if len(atts) == 0 || !strings.Contains(atts[len(atts)-1].Evidence, "outside write scope") { + t.Fatalf("scope failure evidence = %+v", atts) + } +} + func TestMergeRunsOnlyAfterApproval(t *testing.T) { g := &graph.Graph{Nodes: []*graph.Node{ workerNode("w1", "a.txt", "A1", "a.txt"), diff --git a/internal/spike/server.go b/internal/spike/server.go index 633a092..a4b0a75 100644 --- a/internal/spike/server.go +++ b/internal/spike/server.go @@ -6,6 +6,7 @@ import ( "io" "net" "net/http" + "os" "os/exec" "time" ) @@ -19,6 +20,14 @@ type Server struct { // StartServer starts an opencode serve process. port 0 picks a free port; // a fixed port allows restarts on the same URL (daemon watchdog). func StartServer(ctx context.Context, workdir string, port int, stderr io.Writer) (*Server, error) { + return StartServerWithConfig(ctx, workdir, port, stderr, "") +} + +// StartServerWithConfig starts OpenCode with trusted managed agent policy +// injected through OPENCODE_CONFIG_CONTENT. The environment-level config is +// available for sessions whose directory is a generated worktree, where the +// main checkout's ignored opencode.json is intentionally absent. +func StartServerWithConfig(ctx context.Context, workdir string, port int, stderr io.Writer, configContent string) (*Server, error) { bin := "opencode" var cmd *exec.Cmd var base string @@ -34,6 +43,9 @@ func StartServer(ctx context.Context, workdir string, port int, stderr io.Writer cmd = exec.CommandContext(ctx, bin, "serve", "--port", fmt.Sprint(port), "--hostname", "127.0.0.1") cmd.Dir = workdir + if configContent != "" { + cmd.Env = append(os.Environ(), "OPENCODE_CONFIG_CONTENT="+configContent) + } if stderr != nil { cmd.Stderr = stderr } diff --git a/internal/tui/model.go b/internal/tui/model.go index 359471d..3f9a187 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -461,11 +461,11 @@ func (m *Model) nodeAction(label string, fn func(context.Context, string, string }) } -// pendingPermission returns the permission id the node is currently blocked -// on, if its latest blocked transition carried a permission request. -func (m *Model) pendingPermission(nodeID string) (string, bool) { +// pendingPermissionDetails returns the permission request the node is +// currently blocked on, if its latest blocked transition carried one. +func (m *Model) pendingPermissionDetails(nodeID string) (string, string, string, bool) { if m.detail == nil || m.detail.States[nodeID] != "blocked" { - return "", false + return "", "", "", false } for i := len(m.detail.Events) - 1; i >= 0; i-- { ev := m.detail.Events[i] @@ -475,15 +475,24 @@ func (m *Model) pendingPermission(nodeID string) (string, bool) { var p struct { Reason string `json:"reason"` PermissionID string `json:"permissionID"` + Tool string `json:"tool"` + Input string `json:"input"` } if json.Unmarshal(ev.Payload, &p) == nil && p.Reason == "permission" && p.PermissionID != "" { - return p.PermissionID, true + return p.PermissionID, p.Tool, p.Input, true } // Only the latest transition into blocked describes why the // current blocked state exists. Never fall back to an older request. - return "", false + return "", "", "", false } - return "", false + return "", "", "", false +} + +// pendingPermission returns the permission id the node is currently blocked +// on, if its latest blocked transition carried a permission request. +func (m *Model) pendingPermission(nodeID string) (string, bool) { + id, _, _, ok := m.pendingPermissionDetails(nodeID) + return id, ok } // permissionAction answers the pending permission of the node under the diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 864da21..27ac99e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -230,7 +230,7 @@ func TestPermissionRespond(t *testing.T) { d.States["w1"] = "blocked" d.Events = append(d.Events, EventView{ Seq: 2, NodeID: "w1", Type: "transition", From: "running", To: "blocked", - Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-9"}`), + Payload: json.RawMessage(`{"reason":"permission","permissionID":"perm-9","tool":"Bash","input":"{\"command\":\"git status\"}"}`), }) api := &fakeAPI{} m := New(api, context.Background()) @@ -242,11 +242,17 @@ func TestPermissionRespond(t *testing.T) { // The pending permission is surfaced in the detail view. view := m.View() - for _, want := range []string{"perm:perm-9", "p allow perm", "d deny perm"} { + for _, want := range []string{"perm:perm-9", "Bash", "p allow perm", "d deny perm"} { if !strings.Contains(view, want) { t.Fatalf("detail view missing %q:\n%s", want, view) } } + m.mode = modeInspect + m.inspectNode = "w1" + if view = m.View(); !strings.Contains(view, "git status") { + t.Fatalf("inspect view missing permission input:\n%s", view) + } + m.mode = modeDetail send(t, m, key("p")) send(t, m, key("d")) diff --git a/internal/tui/view.go b/internal/tui/view.go index 79b3072..52ae545 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -181,8 +181,11 @@ func (m *Model) nodeLine(n GraphNode, deps int) string { } permS := "" if state == "blocked" { - if pid, ok := m.pendingPermission(n.ID); ok { + if pid, tool, _, ok := m.pendingPermissionDetails(n.ID); ok { permS = styleTitle.Render(fmt.Sprintf("perm:%s", safeText(pid))) + if tool != "" { + permS += styleMuted.Render(" " + safeText(tool)) + } } } bar := m.nodeBudgetBar(n, atts, state) @@ -350,8 +353,15 @@ func (m *Model) viewInspect() string { } b.WriteString(styleDim.Render("verification: ") + safeText(n.Verification.Kind) + " " + strings.Join(command, " ") + "\n") } - if pid, ok := m.pendingPermission(n.ID); ok { - b.WriteString(styleDim.Render("permission: ") + styleTitle.Render(safeText(pid)) + styleMuted.Render(" pending — p allow · d deny") + "\n") + if pid, tool, input, ok := m.pendingPermissionDetails(n.ID); ok { + b.WriteString(styleDim.Render("permission: ") + styleTitle.Render(safeText(pid))) + if tool != "" { + b.WriteString(styleMuted.Render(" tool=" + safeText(tool))) + } + if input != "" { + b.WriteString(styleMuted.Render(" input=" + shortLine(safeText(input), 100))) + } + b.WriteString(styleMuted.Render(" pending — p allow · d deny") + "\n") } b.WriteString("\n" + styleDim.Render("attempts") + "\n") for _, at := range m.detail.Attempts[n.ID] { diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index f2baa17..bf91fe7 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -111,6 +111,15 @@ func (m *Manager) Files(ctx context.Context, worktree string) ([]string, error) return files, nil } +// ChangedFiles lists tracked and untracked (non-ignored) paths changed from +// HEAD. Intent-to-add makes untracked files visible without committing them. +func (m *Manager) ChangedFiles(ctx context.Context, worktree string) ([]string, error) { + if _, err := m.git(ctx, worktree, "add", "-A", "--intent-to-add"); err != nil { + return nil, err + } + return m.Files(ctx, worktree) +} + // HashContent returns the content address of a patch. func HashContent(content string) string { h := sha256.Sum256([]byte(content)) @@ -431,6 +440,29 @@ func ScopesOverlap(a, b []string) bool { return false } +// ScopeContains reports whether path is covered by at least one declared +// relative scope. Empty or "*" scopes intentionally mean the whole worktree. +func ScopeContains(scopes []string, path string) bool { + path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(path)))) + if path == "." || path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(filepath.FromSlash(path)) { + return false + } + if len(scopes) == 0 { + return true + } + for _, scope := range scopes { + scope = strings.TrimSpace(strings.ReplaceAll(scope, `\`, "/")) + if scope == "" || scope == "*" { + return true + } + scope = filepath.ToSlash(filepath.Clean(filepath.FromSlash(scope))) + if path == scope || strings.HasPrefix(path, scope+"/") { + return true + } + } + return false +} + func scopesTouch(x, y string) bool { x = strings.TrimSpace(x) y = strings.TrimSpace(y)