diff --git a/cmd/slmcode/cmd_review.go b/cmd/slmcode/cmd_review.go index d32de9a..189c06d 100644 --- a/cmd/slmcode/cmd_review.go +++ b/cmd/slmcode/cmd_review.go @@ -30,23 +30,50 @@ type pendingPatch struct { Path string `json:"path"` Kind string `json:"kind"` Content string `json:"content"` + // From is the source of a ws_mv proposal (stamped by pkg/workspace). + From string `json:"from,omitempty"` + // TaskID / Agent / QueryID attribute the proposal when known. + TaskID string `json:"task_id,omitempty"` + Agent string `json:"agent,omitempty"` + QueryID string `json:"query_id,omitempty"` } +// Queue kinds that are not "write Content to Path". See writePatch. +const ( + pendingKindDelete = "delete" + pendingKindMove = "mv" + pendingKindShell = "shell" +) + // abs resolves the target file inside the project root. func (p pendingPatch) abs(root string) string { return filepath.Join(root, p.Path) } -// before reads the on-disk content the patch would replace. +// before reads the on-disk content the patch would replace. A move is diffed +// against its SOURCE: the destination does not exist yet, and what the +// reviewer needs to see is that the content is unchanged in transit. func (p pendingPatch) before(root string) string { - data, err := os.ReadFile(p.abs(root)) + path := p.abs(root) + if p.Kind == pendingKindMove && p.From != "" { + path = filepath.Join(root, p.From) + } + data, err := os.ReadFile(path) //nolint:gosec // project path under root if err != nil { return "" } return string(data) } +// after is the content the applier will leave at Path ("" for a delete). +func (p pendingPatch) after() string { + if p.Kind == pendingKindDelete { + return "" + } + return p.Content +} + // diff computes the unified diff for this patch. func (p pendingPatch) diff(root string) cli.FileDiff { - fd := cli.Diff(p.Path, p.before(root), p.Content, 3) + fd := cli.Diff(p.Path, p.before(root), p.after(), 3) if mode, ok := fileMode(p.abs(root)); ok && mode&0o111 != 0 { fd.ModeNote = fmt.Sprintf("mode %04o", mode) } @@ -87,6 +114,11 @@ func loadPending(slmDir string) ([]pendingPatch, error) { if json.Unmarshal(data, &p) != nil || strings.TrimSpace(p.Path) == "" { continue } + if p.Kind == pendingKindShell { + // An older workspace mirrored every shell ask into the queue; + // a command is not a file change and must never be "applied". + continue + } p.File = e.Name() out = append(out, p) } @@ -94,10 +126,64 @@ func loadPending(slmDir string) ([]pendingPatch, error) { return out, nil } -// writePatch applies one patch, preserving the existing file mode. A brand new -// file gets 0o644; an existing executable keeps its +x bits. +// writePatch applies one patch according to its KIND, preserving the existing +// file mode for writes. A brand new file gets 0o644; an existing executable +// keeps its +x bits. +// +// Every kind used to be applied as "write Content to Path", which truncated a +// "deleted" file to zero bytes, copied a moved file while leaving its source +// behind, and wrote a literal shell.sh for a shell approval. func writePatch(root string, p pendingPatch) error { abs := p.abs(root) + if !underRoot(root, abs) { + return fmt.Errorf("refusing %s: the path resolves outside the project", p.Path) + } + if p.Kind == pendingKindMove && p.From != "" && !underRoot(root, filepath.Join(root, p.From)) { + return fmt.Errorf("refusing %s: the source resolves outside the project", p.From) + } + switch p.Kind { + case pendingKindShell: + return errors.New("shell approvals are not file changes; reject the entry instead") + case pendingKindDelete: + if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { + return err + } + return nil + case pendingKindMove: + if strings.TrimSpace(p.From) == "" { + return errors.New("move entry has no source path") + } + src := filepath.Join(root, p.From) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state + return err + } + if _, err := os.Stat(abs); err == nil { + return fmt.Errorf("destination %s already exists", p.Path) + } + if _, err := os.Lstat(src); err != nil { + if !os.IsNotExist(err) { + return err + } + // Source gone in the meantime: the intent is the content at the + // destination, which was recorded. + return os.WriteFile(abs, []byte(p.Content), 0o644) //nolint:gosec // project source file, conventional perms + } + if err := os.Rename(src, abs); err == nil { + return nil + } + data, err := os.ReadFile(src) //nolint:gosec // project path under root + if err != nil { + return err + } + mode := os.FileMode(0o644) + if m, ok := fileMode(src); ok { + mode = m + } + if err := os.WriteFile(abs, data, mode); err != nil { //nolint:gosec // abs is checked against root above; a project source file + return err + } + return os.Remove(src) + } if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state return err } @@ -788,3 +874,15 @@ func matchesAnyPrefix(rel string, prefixes []string) bool { } return false } + +// underRoot reports whether abs lies inside root (lexically: no ".." escape). +// The pending entries are written by the tool layer, which already jails +// paths; this is the applier's own guarantee that a hand-edited entry cannot +// point it elsewhere. +func underRoot(root, abs string) bool { + rel, err := filepath.Rel(root, abs) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/cmd/slmcode/cmd_review_kinds_test.go b/cmd/slmcode/cmd_review_kinds_test.go new file mode 100644 index 0000000..676e8d3 --- /dev/null +++ b/cmd/slmcode/cmd_review_kinds_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeKindFixture(t *testing.T, slmDir, name string, obj map[string]string) { + t.Helper() + dir := filepath.Join(slmDir, "pending") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body, err := json.Marshal(obj) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { + t.Fatal(err) + } +} + +// `slmcode apply --all` used to write every entry's content to its path: +// a delete became a 0-byte file, a move left its source, a shell ask became +// a literal shell.sh. Each kind now does what it says. +func TestWritePatchHonorsKind(t *testing.T) { + root := t.TempDir() + slm := filepath.Join(root, ".slmcode") + + // delete + if err := os.WriteFile(filepath.Join(root, "gone.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := writePatch(root, pendingPatch{Path: "gone.go", Kind: "delete"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "gone.go")); !os.IsNotExist(err) { + t.Fatalf("delete left the file (err=%v)", err) + } + // deleting an already-missing file is not an error + if err := writePatch(root, pendingPatch{Path: "gone.go", Kind: "delete"}); err != nil { + t.Fatalf("second delete: %v", err) + } + + // mv + if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "old", "a.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { //nolint:gosec // executable fixture + t.Fatal(err) + } + if err := writePatch(root, pendingPatch{Path: "new/a.sh", Kind: "mv", From: "old/a.sh", Content: "#!/bin/sh\n"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "old", "a.sh")); !os.IsNotExist(err) { + t.Fatalf("mv left the source (err=%v)", err) + } + if got, err := os.ReadFile(filepath.Join(root, "new", "a.sh")); err != nil || string(got) != "#!/bin/sh\n" { + t.Fatalf("mv destination: %q err=%v", got, err) + } + // mv whose source vanished falls back to the recorded content + if err := writePatch(root, pendingPatch{Path: "new/b.go", Kind: "mv", From: "old/b.go", Content: "package b\n"}); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(root, "new", "b.go")); string(got) != "package b\n" { + t.Fatalf("mv fallback: %q", got) + } + // mv onto an existing destination is refused + if err := writePatch(root, pendingPatch{Path: "new/b.go", Kind: "mv", From: "new/a.sh", Content: "x"}); err == nil { + t.Fatal("mv over an existing destination was allowed") + } + + // shell: never a file + err := writePatch(root, pendingPatch{Path: "shell.sh", Kind: "shell", Content: "rm -rf /"}) + if err == nil || !strings.Contains(err.Error(), "shell") { + t.Fatalf("shell entry applied: err=%v", err) + } + if _, serr := os.Stat(filepath.Join(root, "shell.sh")); serr == nil { + t.Fatal("shell.sh written to the project root") + } + + // loadPending hides stale shell mirrors entirely, and surfaces from/stamps. + writeKindFixture(t, slm, "1000_shell_shell.sh.patch.json", map[string]string{"path": "shell.sh", "kind": "shell", "content": "ls"}) + writeKindFixture(t, slm, "2000_mv_x.patch.json", map[string]string{"path": "n.go", "kind": "mv", "from": "o.go", "content": "p", "task_id": "T1", "agent": "worker"}) + got, err := loadPending(slm) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Kind != "mv" || got[0].From != "o.go" || got[0].TaskID != "T1" || got[0].Agent != "worker" { + t.Fatalf("loadPending = %+v", got) + } + + // The diff of a delete is everything removed; of a move, source vs content. + if err := os.WriteFile(filepath.Join(root, "d.go"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + fd := pendingPatch{Path: "d.go", Kind: "delete"}.diff(root) + if fd.Removed != 2 || fd.Added != 0 { + t.Fatalf("delete diff: +%d -%d", fd.Added, fd.Removed) + } + fd = pendingPatch{Path: "moved.go", Kind: "mv", From: "d.go", Content: "a\nb\n"}.diff(root) + if fd.Removed != 0 || fd.Added != 0 { + t.Fatalf("mv diff should be empty for unchanged content: +%d -%d", fd.Added, fd.Removed) + } +} diff --git a/docs/agents.md b/docs/agents.md index 46049ce..7476d02 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -34,7 +34,7 @@ see Providers. Budget diplomacy is a feature. | `escalate` | — | action JSON | HITL timeout arbitrator (retry/re-scope/…) ⚖️ | | `memory` | — | bullets | Learn 💾 | | `composer` | — | pipeline JSON | Assemble a task-specific pipeline (dynamic_pipeline) 🎯 | -| `reviewer-strict` | — | approve JSON | Second opinion in the speculative review race (`max_parallel >= 3`), temperature 0 🔍🔍 | +| `reviewer-strict` | — | approve JSON | Sequential second opinion, asked only when `reviewer` returns no readable verdict; temperature 0 🔍🔍 | | `describer` | — | prose | Architect half of the describer→editor pair (`architect_editor`) 🗣️ | | `editor` | ✅ + `find_models` / `mcp_call` | status | Editor half: applies a described change, minimal reasoning, strict format ✍️ | diff --git a/docs/calibration.md b/docs/calibration.md index 7fb14a6..01aec23 100644 --- a/docs/calibration.md +++ b/docs/calibration.md @@ -170,6 +170,27 @@ That last one is the delicate one, so it has three guardrails: The seed can only ever tighten a budget for a model fast enough that a role provably does not need the whole ceiling. +Where the two numbers are enforced: + +* **`max_parallel` is one run-wide limit.** Every model request the harness + makes — the phase roles, the execute loop's workers, reviewers and + correctors, self-critique, triage, the speculative review race, the planner's + multipass cycle — takes a slot of a single gate sized `max_parallel`. It used + to bound only the execute wave, while context ran beside explore and a review + race added slots of its own, so a `max_parallel: 1` endpoint still saw two or + three requests queue on each other and every measured role timeout was + inflated by queueing the harness itself created. At `max_parallel: 1` the + phase pairs also run sequentially instead of racing for the one slot. +* **Role latency memory covers the execute loop too.** The measured budget + (`p95 × 1.5`, floored per role class, capped at `task_timeout`) used to apply + only to the phase roles; workers, reviewers and correctors ran on the flat + `task_timeout` and recorded no samples, so `reviewer` had none after a + hundred runs. Each loop request is now dispatched on + `min(task_timeout clamped to the runway, measured budget)` and records a + sample under its base role (an escalation rung shares its base role's + series). A reviewer measured at 20s is given its 60s floor, not the whole + ceiling. + ## Commands ``` diff --git a/docs/changelog.md b/docs/changelog.md index c039793..a030f9e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -85,6 +85,117 @@ run happens to assemble some — and the Live view shows them working. - A run started after `squads` was switched off in a long-lived Studio process could inherit the previous run's org chart. +### Efficiency — fewer, shorter, better-aimed model calls + +A review of where a run's wall clock and tokens actually went, then the fixes. +The execute loop owns most of both, so most of this is there. + +- **One reviewer per review.** The review used to be a race: a local + "acceptance" probe, the reviewer and a strict second reviewer all dispatched + at once, the strict verdict then discarded unless the first came back empty. + Now the fast path decides what it can, hard gates that already failed reject + without a model, one reviewer is asked, and the strict opinion is sought + only when the first answer is unreadable. The acceptance probe could also + approve a task with an unverified must-criterion and no reviewer; it is gone. +- **The corrector sees why it was rejected.** Its prompt head-clipped the + worker's output, so a long reasoning trace hid the failing command and its + output appended at the end. It now keeps the harness evidence whole and + carries the focus files, acceptance and language, the same split the + reviewer already had. +- **The worker prompt puts the stable pack first** and the task header just + before the instructions, so every task in a wave shares the packed prefix + and the server's KV cache is reused instead of re-prefilled per task. +- **Old tool results are elided on live requests.** A worker's transcript + re-sent every `ws_read` verbatim on every later turn; a deterministic wrapper + now keeps the last five tool results and elides the rest, per request, + without touching the system message or the tool-call pairs. + `react_compact` is finally what the docs said it was. +- **One run-wide concurrency limit.** The measured knee (`max_parallel`) was + enforced only in the execute wave; prepare phases, speculation, reviews and + critique all ran past it. One gate at the executor boundary now covers every + model call, and paired phases run sequentially at `max_parallel: 1`. +- **Measured role budgets reach the loop.** Workers, reviewers and correctors + ran on the flat `task_timeout`; they now get the per-role p95 budget the + phase roles already had, and their latencies feed the same store. +- **The acceptance command runs once per tree state.** A finish used to re-run + it up to four times on an unchanged tree (pre-test, team gates, integration, + QA round one); a per-run memo keyed by the tree fingerprint answers the + repeats, and team and integration commands check the runway first. +- **Phases with nothing to do are skipped**, and say so: the memory + distillation with no lessons, no changes and no failures; the per-wave + distill and coordinator after an uneventful wave; the splitter for a + one-step plan. +- A finished team is proved as soon as it finishes, between waves, so its + correction ticket rides the next wave instead of waiting for the whole + board. A seam task that straddles two teams is handed the whole contract. +- Every dispatch path honors the run's remaining runway; an explicitly set + temperature of 0 is sent rather than treated as unset; the steer a resumed + worker is given reaches the model even when its checkpoint ends on pending + tool calls. + +### Robustness + +- **A reviewer that times out or errors no longer blocks the task** and every + task behind it. The gathered evidence is judged, the reviewer is re-asked + once when the budget allows, and only then is the task parked for a person. +- **A worker that times out is resumed, not parked**, from its checkpoint, + until the attempt ceiling; transient provider errors get one bounded retry + honoring `Retry-After`, on the streaming path too (which never retried + while someone was watching). +- **The path jail held for symlinks but not dangling ones.** A link whose + target did not exist passed the check and the write followed it outside the + workspace. Refused now. +- Studio's Run and Stop no longer race: a stop leaves the run marked as + stopping until it has really exited, a new run cannot inherit or clobber the + old one's state, and a second click on Run cannot delete a pending ask. +- `slmcode apply` and the Review page apply proposals by kind: a proposed + delete deletes (it used to truncate the file to nothing), a move moves, and + shell approvals are no longer written to the tree as `shell.sh`. +- Parallel workers asking for shell approval each get their own ask and answer + file; one used to overwrite the other and the loser deleted the winner's + answer. +- `ws_edit`/`ws_write` are kinder to small models: `old_string`/`search`, + `new_string`/`replace` and `contents`/`text` are accepted; a `ws_read` line + gutter in `old_str` is stripped and noted instead of refused; writing a new + file with no content is refused with the accepted keys named; + `replace_all` works on drifted matches. `ws_patch` inserts a pure-addition + hunk after the line it names (it landed one line early), the bare + `SEARCH/=======/REPLACE` form works, and trailing prose is ignored. +- Team gates, the squad plan and the single team are reset per run; a + long-lived Studio process could carry a green gate into the next run's + verdict. +- The event log is written through one buffered appender per run and token + deltas are not persisted; `/api/runs/latest` no longer encodes under the + lock that the run's own emits need; the syntax guard runs one checker per + edit, not two. + +### Studio + +- **The board is live.** `task_update` and `review_pending` stream events + replace the four pollers; the Board, the floor, the Teams page and the Review + badge change the moment a task moves. +- **Everything links.** Task, agent, team, run and file identifiers are one + chip everywhere and lead to the same place: a task or a person opens on the + Live floor with its dossier (`?task=`, `?agent=`), and the selection survives + navigation and reload. Board filters live in the URL too. +- **A stuck task tells its story**: attempts, the reviewer's verdict and the + criteria, with Retry (`POST /api/tasks/{id}/retry`), Send back to Ready, + Reassign and Steer. Blocked and Failed counters are filters. +- **A run's end is a beginning**: Resume, open the blocked tasks, review the + pending changes, or run again; a toast, a title flash and (when granted) a + notification if you are on another page; a short celebration on green. +- Saves and loads that used to fail silently (Settings during a run, docs, + agents, skills, pipeline, files) now say what happened and offer a retry. + Dialogs trap focus and close on Esc; tabs roam with the arrow keys; the 3D + floor can be walked by keyboard and reads out its people and tickets. +- The floor remembers its camera, follow and pulses across pages, renders on + demand when nothing moves, allocates nothing per frame, falls back to the + map if WebGL is lost, and fits a phone. The log is windowed and the run's + derived state is folded per event, so a two-hour run stays smooth. +- Keyboard: `g t` for Teams, `⌘/Ctrl+Enter` to run and `⌘/Ctrl+.` to stop + from the prompt, `⌘/Ctrl+K` command palette. The Runs list shows duration, + tokens, cost, tasks and teams; Review shows who proposed each change. + ## v0.24.0 — 2026-09-01 Ten defects found by running v0.23.0 against a local 30B, thirteen times. Nearly diff --git a/docs/context.md b/docs/context.md index 9b7b75b..cb11be6 100644 --- a/docs/context.md +++ b/docs/context.md @@ -179,11 +179,27 @@ Anything that stayed a card can be pulled on demand with the `ws_skill` tool. ## 7. Compaction (`pkg/compact`) -### ReAct compaction (mid-run conversation) - -Triggers at `react_compact_at_percent` (default 80) of the window, with a 5-point hysteresis band -— a compaction that does not open at least that much headroom pauses auto-compaction rather than -thrashing. +### ReAct compaction + +Two paths share one threshold: `react_compact_at_percent` (default 80) of the model's context +window (`context_limit` from the model profile). `loop.LiveReactCompactionWired` is the constant of +record for whether the live path exists; `react_compact` says whether it is used. + +**Live iterations — deterministic elision only.** A ReAct iteration is one provider completion, and +every role is bound to its own provider registration, so a provider wrapper +(`pkg/backends.LiveElide`, installed by `BindRole` for tool-using roles) sees each iteration's full +transcript. Once the estimated transcript passes the threshold, the content of every tool *result* +but the last 5 is replaced with `[tool result elided]`. Every tool *call*, every `tool_call_id` +pair, the leading system message and every user/assistant turn stay — a transcript that was legal +before is legal after. Nothing is summarized on a live request: the head of the transcript is the +role's tool contract, and dropping it mid-call breaks the agent more reliably than a long context +does. The rewrite is per request and never touches the agent's own conversation, so it is +repeatable, and the elided prefix stays byte-identical between iterations until the keep window +slides — KV-cache reuse survives. + +**Checkpoint and resume — elision, then a digest.** A restored or checkpointed transcript is +compacted by `maybeCompactReact`, with a 5-point hysteresis band — a compaction that does not open +at least that much headroom pauses auto-compaction rather than thrashing. Two invariants: diff --git a/docs/decoding.md b/docs/decoding.md index 9af3c50..fc5c42f 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -107,6 +107,15 @@ to its first tool call. The prompt asks for one; the transport guarantees it. Language-specialised ids fold back to their generic role (`go-worker` → `worker`, `python-tester` → `tester`), so YAML-defined agents inherit the right contract automatically. +**Temperature 0 is a setting, not an absence.** The request encoding treats a zero temperature as +unset — GoLangGraph substitutes the provider's default and the OpenAI-compatible body omits the +key — so a role pinned at 0 (`reviewer-strict`) was in fact sampled at the server's default. A +spec that sets a temperature deliberately marks it `TemperatureSet`, which travels with the role's +directives, and the structured path then always emits `temperature` for that role, zero included. +Roles that never set one keep the unset behavior. The delegated (non-structured) request path is +built inside GoLangGraph and still applies its own default to a zero; the constrained-decoding +path is the one `reviewer-strict` takes. + ## 4. The repair ladder (`pkg/repair`) When output still arrives unconstrained, the ladder is tried in a fixed order, and the name of diff --git a/docs/permissions.md b/docs/permissions.md index e62e577..b56b2c6 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -19,6 +19,12 @@ Aliases accepted: `allow`/`yes` → `auto`; `dryrun`/`dry` → `dry-run`; `ask`/ In `review` mode, a tool result reads `review: staged pkg/foo/bar.go → 1712…_edit_pkg__foo__bar.go.patch.json (run \`slmcode apply\`)`. +Each queue entry records its `kind` (`write`/`edit`/`patch`/`delete`/`mv`) and, when known, the +`from` path of a move plus the `task_id`, `agent` and `query_id` that produced it. Both appliers +act on the kind: a `delete` removes the file, an `mv` moves the source, and a shell approval is +never a queue entry at all (it is answered through `slmcode`'s shell-ask prompt or Studio, one file +per ask under `.slmcode/shell/asks/`). + ```bash slmcode config set permission review slmcode apply # interactive per-file review diff --git a/docs/pipeline.md b/docs/pipeline.md index 57cf7c9..389f647 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -192,6 +192,36 @@ Custom presets can be created as YAML blocks — see [🧱 Blocks](blocks.md). --- +## What a run skips, and why + +A phase that is *enabled* still only runs when it has something to do. Every +skip is announced as an info event in the phase's own lane, so the Live view +and the CLI show the reason rather than a phase that silently did not happen. + +| Phase | Runs when | Skipped with | +|-------|-----------|--------------| +| `split` (the splitter) | the plan has two or more steps, or one step over more than two known files, or no known file at all | `splitter skipped — a 1-step plan over N known file(s) is one task; building the board directly`. The board is built from the plan's one step with the known files as its scope. | +| per-wave coordinator (`coord @after-wave`) | the wave that just finished failed a task, escalated one, or produced a lesson worth keeping — a failure lesson or an honored human note; the routine "this task passed its acceptance" lessons every green task yields do not count | `coordinator @after-wave skipped — all N task(s) finished green with no failure, escalation or new lesson` | +| per-wave distillation (`learn`, `think_passes >= 2`) | same rule as the coordinator | `wave distillation skipped — …` | +| `memory` (end-of-run distillation) | the run left a lesson worth keeping (same notion), changed a file, or failed a task | `memory distillation skipped — nothing to distill: no lessons, no changed files, no failed tasks`. The distiller is a single call now, not a multipass cycle. | + +Two more things the pipeline no longer repeats: + +- **One concurrency limit for the whole run.** `max_parallel` used to bound only + the execute wave; context ran beside explore, architect beside clarify, + speculative digs and review races added slots on top. Every model request — + phase roles, workers, reviewers, correctors, critique, triage, the planner's + multipass cycle — now takes a slot of one run-wide gate sized `max_parallel`, + and at `max_parallel: 1` the phase pairs run one after another. +- **The objective command runs once per tree state.** The deterministic + pre-test, each team's acceptance, the integration command and the QA gate's + first round all ask the same question of the same tree; a result is + remembered per (command, tree fingerprint) and reused until something is + written — an agent write, a rewrite of an already-changed file, a formatter + pass or a dependency install all count. + +--- + ## Related - [Blocks](blocks.md) — building blocks system + marketplace diff --git a/docs/squads.md b/docs/squads.md index 2e8f934..cfe6e37 100644 --- a/docs/squads.md +++ b/docs/squads.md @@ -257,6 +257,15 @@ stay **unassigned**, exactly as before: A file nobody owns cannot go to a piece, and dropping it would silently narrow the work, so the whole task is left alone. +A task that stays unassigned **because** it straddles is still handed the +contract. It used to get no brief at all — a worker on no team got no squad +brief, and the one agent on the run building both sides of the seam was the one +building without the interface spec. It now gets a **seam brief**: the squads it +spans and what each owns, then every interface of the frozen contract with its +provider, consumers and a clipped spec. Interfaces only, no charters — the +worker is on neither team and needs the seam, not the missions — capped at a +dozen interfaces so it stays a brief on a 30B-class prompt. + ### 3d. The halves stop waiting on each other Letting both teams build **at once** is the point of freezing the seam. The @@ -501,6 +510,42 @@ nothing: The result is on the board, per team: **proved green**, **half is red**, or **unverified** with the reason. +### A lane is proved the moment it finishes + +The finish path proves every half once the *whole* board has drained. On a +two-team run that is late: the backend can finish in wave 1 with a broken build +and sit green-on-the-board for every wave the frontend still needs, its ticket +raised only after the frontend's last task — a whole extra wave the run may no +longer have time for. So a half is also proved **between waves**, as soon as +`squads.Progress` reports its lane complete and the run has written inside its +territory since it was last proved: + +```text +verify team backend-go finished its lane — proving its half before the next wave +verify team backend-go is RED — its own half does not pass: FAIL: TestMain +verify raised a correction ticket for team backend-go +verify team backend-go's correction ticket rides the next wave +``` + +Three rules keep this cheap and bounded: + +- **Once per lane state.** A team is re-proved only when *its own* changed files + moved (a rewrite of an already-changed file counts; the other team's writes do + not). Nothing written in its territory yet means nothing to prove yet. +- **Once per tree state, across every gate.** Command results are remembered per + (command, tree fingerprint) for the run, so the finish-path gate on a tree + nothing has touched since costs no second run — nor does the QA gate's first + round after the pre-test, nor the integration command after the halves. +- **Two tickets per defect, then a human.** The same acceptance failing again + after its ticket was worked to done is a defect the team cannot fix alone; + a third ticket is refused and said so. Without the cap a half that stays red + would be ticketed on every wave until the run hit its ceiling. + +A gate that would not fit in the time left is skipped with a reason and the half +reported UNVERIFIED, for the between-wave gate, the finish-path gate and the +integration command alike — overrunning the report is worse than one unproved +half. + ### A failing run says which halves were proved A task that escalates fails the whole run — the escalation is exactly the signal diff --git a/docs/studio.md b/docs/studio.md index 5511040..bef4a97 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -79,8 +79,50 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build. | `/settings` | **Settings** | Provider, model, stacks, packs, HITL modes, parallelism, MCP, API keys | A global **HITL modal** surfaces clarify / plan-approve / continue / escalate / shell gates from -any page — you no longer have to be on the Live view to answer one. A **connection badge** shows -stream health, and an error boundary keeps one broken panel from blanking the app. +any page — you no longer have to be on the Live view to answer one. It traps Tab and answers Esc +by returning focus to the decision (a gate cannot be dismissed — the harness is waiting). A +**connection badge** shows stream health, and an error boundary keeps one broken panel from +blanking the app. + +### When a task gets stuck + +A blocked or failed card tells its story instead of showing one error line: the **Attempts** +timeline (`attempt_log` — "attempt 2 failed because …"), the gate-retry count, the **review +verdict**, and the structured **criteria** checklist. Under it, a next-step row: + +| Action | What it does | +|---|---| +| **Send back to Ready** | `PATCH /api/tasks/{id}` → column `ready_to_dev`; the next wave picks it up | +| **Retry** | `POST /api/tasks/{id}/retry` — same, keeping the attempt log so the next try knows what failed (falls back to the Ready move on a server without the endpoint; `409` means no board is loaded) | +| **Reassign team** | opens the edit form on the team control | +| **Steer** | prefills the live-feedback composer with `@task:ID ` — from the board it navigates to Live first | + +The same row appears in the Live task panel, whose **Blocked / Failed** counters are filters over +the list. The panel accepts a `focusTaskId` prop (and Live reads `/?task=ID`) to expand, scroll +to and flash one task. + +### When a run ends + +The result panel's **What next** row offers whichever of these apply: **Resume interrupted run**, +**Open blocked on Board** (`/board?column=blocked`), **Review N pending**, and **Run again with +this prompt**. The app also fires a toast, flashes the tab title while the tab is hidden, sends a +browser notification when the tab is hidden *and* permission was already granted (Studio never +asks for it on its own), and throws a two-second confetti burst on a green run — not under +`prefers-reduced-motion`. + +### Keyboard + +| Keys | Does | +|---|---| +| `?` | shortcut sheet | +| `⌘K` / `Ctrl+K` | command palette — pages, tasks by id or title, agents, teams, recent runs, theme | +| `/` | focus the run prompt | +| `⌘↵` / `Ctrl+Enter` | run the prompt, while it is focused | +| `⌘.` / `Ctrl+.` | stop the active run, while the prompt is focused | +| `g` then `l` `b` `r` `p` `a` `t` `k` `f` `s` `h` `,` | go to Live, Board, Review, Pipeline, Agents, Teams, Blocks, Files, Skills, Runs, Settings | +| `Esc` | close a dialog, the sheet or the palette | + +Plain-key shortcuts are inert while typing in a field; the modifier ones are not. --- @@ -135,7 +177,72 @@ pipeline*. Where WebGL is unavailable, or on request (the **3D / map** toggle), the same floor is drawn as a flat map with the same dossier and feed. Both honor -`prefers-reduced-motion`. +`prefers-reduced-motion`. On a touch device with no stored preference the map +is the default; below the `sm` breakpoint the toggle sits in a strip under the +floor and the feed opens from it as a bottom sheet. Every person and ticket in +the 3D scene is also a visually hidden button, so the floor can be walked with +a keyboard: a dossier takes focus when it opens and gives it back when it +closes, and `Esc` closes it unless it was typed into a field. + +The scene keeps its state between visits. The **selection** lives in the URL — +`/?task=T4` opens a ticket's dossier, `/?agent=go-worker` a person's, with an +optional `&team=` — so a dossier survives navigating away, a reload, and being +pasted to someone. The camera, **follow**, **spin** and the pulse feed live in +a module-level store (mirrored to `sessionStorage`), so coming back from the +Board finds the floor where it was left; **reset view** restores the camera +through OrbitControls rather than remounting the scene. The render loop runs +continuously only while something moves (a run, live tickets, fresh pulses, +follow, spin, a camera glide), drops to on-demand when the floor is still, and +stops while the tab is hidden. If the browser loses the WebGL context the page +falls back to the flat map and says so. + +## One board, one stream + +Every page that shows tasks reads the same **board store** (`web/src/hooks/ +useBoardStore.ts`, provided from `App`): the Board, the Live floor, the ticker +and the Teams page. It is seeded once from `GET /api/tasks` and +`GET /api/squads`, then kept current by the live stream, and re-read every +30 s as a safety net, on both edges of a run, and when the stream reconnects. +Before the server's first push, a structural log line (a task starting or +finishing, a run starting or ending) also triggers one debounced re-read, so a +server without the events below still feels live. Pages call `refresh()` +after a write and `upsertTask()` for the optimistic paint. Nothing polls on +its own timer any more; the Sidebar's review badge comes from the stream too. + +Two SSE kinds are **board events**: they are folded into the store and never +become rows in the log, since one row per column move would bury the story +the log tells with `task_start` / `task_done`: + +| Kind | Payload | Effect | +|---|---|---| +| `task_update` | `task_id`, `data.task` — the task exactly as `GET /api/tasks` returns it | replaced by id in the store (appended if new); marks the store *live* | +| `review_pending` | `data.pending` — the review queue's length | updates the Review badge at once | + +Their sequence ids still advance the reconnect cursor, and a replayed one is +dropped like any other duplicate. + +The stream also keeps a **running derivation** of the log (`web/src/hooks/ +runDerived.ts`): phases seen, the active phase and agent, task ids, files +written, token and cost totals, the newest composition — folded once per event +as it arrives rather than re-scanned by each panel on every flush. The +ticker's clock keys on the floor model's working set (an agent between its +`agent_start` and `agent_end`), so it stops when nobody is working. + +### Links between pages + +Identifiers are chips that go somewhere (`web/src/components/shared/ +EntityLink.tsx`): + +| Kind | Goes to | +|---|---| +| task | `/?task=ID` — the ticket's dossier on the floor, and the Tasks rail | +| agent | `/?agent=ID` — the person's dossier | +| team | `/teams?team=ID` | +| run | `/runs?run=ID` | +| file | `/files?file=path` | + +The Board keeps its own filters in the URL as well: `?team=ID` (or the +unassigned lane) and `?column=ID`. ## The review workflow @@ -147,20 +254,37 @@ slmcode run "add JWT validation" ``` Each proposal is a `.slmcode/pending/__.patch.json` holding -`{path, kind, content}`. Studio's **Review** page lists them with both sides of the diff and -per-file apply/reject; the same queue is available from the terminal with `slmcode apply` and -`slmcode reject`. +`{path, kind, content}` plus, when the workspace knew them at record time, `from` (the source of a +`ws_mv`), `task_id`, `agent` and `query_id`. Studio's **Review** page lists them with both sides of +the diff and per-file apply/reject; the same queue is available from the terminal with +`slmcode apply` and `slmcode reject`. + +**Apply honors the kind.** `write`/`edit`/`patch` write the content; `delete` removes the file; +`mv` moves the source to the destination (falling back to writing the recorded content if the +source is gone). Stale `shell` entries from older builds are never files: they are hidden from the +listing, skipped by `{all: true}` and refused by id. | Endpoint | Purpose | |---|---| -| `GET /api/review/pending?hunks=1&context=3` | list pending changes, optionally with hunks | -| `GET /api/review/pending/{id}` | one change with its diff | -| `POST /api/review/apply` | `{ids}` / `{id}` / `{all: true}` | -| `POST /api/review/reject` | same shape | +| `GET /api/review/pending?hunks=1&context=3` | list pending changes, optionally with hunks. Items carry `kind`, `from`, `task_id`, `agent`, `query_id` (empty when unknown) | +| `GET /api/review/pending/{id}` | one change with its diff (a `delete` diffs to empty; an `mv` diffs source → content at the destination path) | +| `POST /api/review/apply` | `{ids}` / `{id}` / `{all: true}` — emits `review_pending` | +| `POST /api/review/reject` | same shape — emits `review_pending` | The queue id is a bare file name and is validated as one — a traversal attempt is rejected rather than resolved. +### Board, tasks and history + +| Endpoint | Purpose | +|---|---| +| `GET /api/tasks` | the live board; each task includes `attempt_log`, `gate_retries` and `criteria` when set | +| `POST /api/tasks/{id}/retry` | move a task back to `ready_to_dev`, reset `retries`, clear `error`, append `"retried from Studio"` to `attempt_log`, persist, emit `task_update`. Returns the task. `409` when no board is loaded, `404` for an unknown id | +| `GET /api/queries` | run history; every item also carries `duration_ms`, `tokens`, `cost_usd`, `tasks_total`, `tasks_done`, `failed_tasks` and `teams` (string list), computed from the stored turn, board and event log (usage totals are cached per log size/mtime) | +| `GET /api/shell/pending` | `{pending, ask, asks: [...], count}` — `ask` is the oldest open shell ask (the shape the UI was built on); `asks` lists every open one, since a parallel wave can raise several | +| `POST /api/shell/approve` | `{ask_id, decision: "approve" \| "deny"}` — `ask_id` picks which pending ask; it may be omitted only when exactly one is pending | +| `POST /api/runs/stop` | asks the run to unwind. `running` stays `true` (with `stopping: true` in `/api/runs/latest` and `/api/status`) until the run goroutine has exited, so a new run cannot start on top of the old one's teardown | + --- ## Live events (SSE) @@ -174,6 +298,20 @@ than resolved. - When events genuinely could not be replayed, an explicit `event: gap` frame is emitted with `{from, to}`, so the UI can say *"events N–M were dropped"* instead of quietly showing an incomplete run. A slow consumer is flagged rather than silently dropped. +- Kind `run_end` (phase `done` or `error`, message = the summary) is what the run-end toast, + title flash and notification key on. Kind `review_pending` (`{pending: N}`) tells the Review + page to refresh while a run is still writing; without it the page polls every 15 s during a run. +- The ring is **not** cleared when a run starts. `GET /api/runs/latest` scopes its snapshot to the + current run by sequence number, while a reconnecting stream can still replay across a + stop → start. + +Two kinds are synthesized by the server itself rather than emitted by the engine, and they sit in +the ring and replay like every other event: + +| Kind | When | Shape | +|---|---|---| +| `task_update` | after any board task change (worker moves, a `PATCH /api/tasks/{id}`, a retry) | `phase` = the run's current phase, or `"board"` when no run is active; `task_id`; `message` = `" -> "` (`" -> removed"` for a deletion); `data` = `{"task": {…task exactly as GET /api/tasks renders it…}}` | +| `review_pending` | after a proposal is recorded by a running agent, and after every apply/reject | `phase` = `"review"`; `data` = `{"pending": N}` — the number of applicable entries in the queue | `GET /api/queries/{id}/events` replays a recorded run's log, and `GET /api/queries/{id}/trace` groups it into contiguous phase segments with totals — the numbers that matter when tuning a @@ -343,6 +481,31 @@ otherwise. `react-hooks/exhaustive-deps` is an **error**, not a warning: a stale closure in the SSE handler once reduced the live event log to a single row, and that rule is what catches it. +### Cross-component contracts + +A few interactions cross component boundaries without a shared parent. They go through +`CustomEvent`s on `window`, all named in `web/src/components/ui/events.ts`. Every event is +dispatched `cancelable`; a listener that handles it calls `preventDefault()`, and the dispatcher +falls back to doing the work itself when nobody does. + +| Event | Fired by | Listened to by | +|---|---|---| +| `slmcode:focus-prompt` | `/` | Live view — focuses the prompt | +| `slmcode:run-prompt` (`detail.query?`) | `⌘↵` in the prompt; the result panel's *Run again* | Live view — starts the prompt (or `detail.query`) with the chosen teams/specialist; the result panel calls `POST /api/runs` directly when unclaimed | +| `slmcode:stop-run` | `⌘.` in the prompt | Live view — `POST /api/runs/stop` | +| `slmcode:steer-task` (`detail.taskId`) | a task's *Steer* | `LiveFeedback` — prefills `@task:ID `; parked in `sessionStorage` when no composer is mounted | +| `slmcode:command-palette` | anything | `Layout` — toggles the palette | + +The prompt input is recognised by its accessible name `Run prompt` (or a `data-run-prompt` +attribute). URL parameters that pages read: `/board?column=blocked` (Board), `/files?path=a/b.go` +(Files), `/runs?run=ID` (Runs), `/?task=ID` (Live → task panel `focusTaskId`), +`/?run=ID&replay=1` (Runs → *Replay on the floor*; harmless when Live ignores it). + +Errors on every editor page go through `useToast().reportError`; list pages render +`ui/ErrorState` with a Retry in place of the list when their load fails. Tab strips use +`ui/useRovingTabs` (one Tab stop, arrows between tabs, `aria-controls` to the panel). Dialogs +share `useFocusTrap` from `ui/Modal.tsx`. + `make ui-react` builds and syncs `web/dist/` into `cmd/slmcode/ui/`, which is embedded with `go:embed all:ui`. `make bootstrap` does the same but only when the assets are missing. diff --git a/docs/tools.md b/docs/tools.md index d30cc69..9effafa 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -70,8 +70,11 @@ Next page: ws_read {"path":"pkg/foo/bar.go","offset":121,"limit":120}. To jump s ``` The ` 42|` gutter is **display only**. Including it in `old_str` is the single most common -small-model edit failure, so `ws_edit` detects and rejects it by name rather than reporting a -generic miss. +small-model edit failure. When **every** non-blank line of `old_str` (or of a `ws_patch` body) +carries the gutter the intent is unambiguous: `ws_edit`/`ws_patch` strip it, apply the edit, and +append `[stripped ws_read line numbers from old_str — do not include them]` to the result so the +drift stays visible to `pkg/evolve`. A gutter on only *some* lines is not a paste and is still +refused by name. Failure messages point at the recovery tool: a missing path suggests `ws_glob`/`ws_list`, a directory suggests `ws_list`, an out-of-range offset gives the valid range. @@ -120,10 +123,13 @@ and how `pkg/evolve` learns which drift your model has. | Situation | Response | |---|---| | `old_str` empty or whitespace-only | Refused. Empty search used to pass `strings.Contains` and silently prepend. The message names the three real intents: create → `ws_write`; append → anchor on the last 2–3 lines; insert → repeat the anchor in `new_str`. | -| `old_str` carries the ` 42\|` gutter | Refused by name, with a before/after example. | +| `old_str` carries the ` 42\|` gutter on **every** line | Stripped and applied; the result ends with `[stripped ws_read line numbers from old_str — do not include them]`. | +| `old_str` carries the gutter on only **some** lines | Refused by name, with a before/after example. | | `old_str == new_str` | `No-op edit refused — old_str and new_str are identical.` | | Exact match found N>1 times | `old_str found N times … pass replace_all:true, or include more surrounding context … Do NOT use ws_write.` | -| A ladder strategy matched N>1 times | `Ambiguous edit refused — the search text matches N places … (strategy) match.` | +| A ladder strategy matched N>1 times | `Ambiguous edit refused — the search text matches N places … (strategy) match.` With `replace_all:true` every span of that strategy is replaced instead (`edited … (N replacement(s)) [matched …]`). | +| Argument spelled `old_string`/`search`/`old`, `new_string`/`replace`/`new` | Accepted as aliases of `old_str`/`new_str` (canonical key wins when both are present). `ws_write` likewise reads `contents`/`text`/`body`, `ws_patch` reads `diff`/`hunk`. | +| `ws_write` with empty `content` on a new file | `Write refused — content is empty, so this would create as a 0-byte file.` The message names the accepted body keys and any unrecognized key in the call. Emptying an existing file needs `allow_shrink:true`. | | No strategy matched | Not-found guidance plus a fuzzy hint at the closest span. | | Whole-file-style rewrite through `ws_edit` | Refused by the over-edit guard (`over_edit_guard`). | @@ -135,8 +141,15 @@ Success: `edited pkg/foo/bar.go (1 replacement(s))` plus any strategy note and s {"path": "pkg/foo/bar.go", "patch": "@@ -10,3 +10,4 @@\n …"} ``` -Accepts a unified diff with `@@` hunks, a `<<<<<<< SEARCH / ======= / >>>>>>> REPLACE` block, or -a bare `-`/`+` block treated as one anchorless hunk. +Accepts a unified diff with `@@` hunks, a `<<<<<<< SEARCH / ======= / >>>>>>> REPLACE` block, the +same block **without** the conflict markers (`SEARCH` / `=======` / `REPLACE` header lines, or just +a bare `=======` separator), or a bare `-`/`+` block treated as one anchorless hunk. + +A pure-insertion hunk (`@@ -N,0 +M,K @@`, nothing removed) inserts **after** line N, as every +unified-diff consumer does (`-0,0` means the top of the file). Trailing prose after a hunk ("This +adds the check.") and Markdown fences around the diff are dropped rather than being read as +context lines that can never match; an unprefixed line that is *followed* by a diff line is still +treated as context the model forgot to prefix. Multi-hunk diffs are applied **hunk by hunk**, each anchored on its `@@` line numbers within a ±20-line window (`AnchorWindowLines`), with earlier hunks' line delta carried forward. The same diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c7e9c42..0050762 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,11 +36,18 @@ slmcode doctor --json These arrive **in-band**, as the tool result the model sees. They are also what you will see in the Live view and the run trace. +### `[stripped ws_read line numbers from old_str — do not include them]` + +The commonest small-model failure, now handled in-band. `ws_read` renders a ` 42|` gutter for +navigation; it is not in the file. When **every** non-blank line of `old_str` (or a `ws_patch` +body) carries it, the gutter is stripped, the edit is applied, and this note is appended to the +result so the drift stays visible. `pkg/evolve` ships a rule +(`transform_args: strip_line_number_prefix`) keyed on it that stops the model doing it again. + ### `Edit refused — old_str still contains ws_read's line-number prefix (like ` 42|`)` -The commonest small-model failure. `ws_read` renders a ` 42|` gutter for navigation; it is not -in the file. The message shows a before/after. `pkg/evolve` ships a rule -(`transform_args: strip_line_number_prefix`) that fixes this automatically after the first time. +Only **some** lines carry the gutter, so this was not a paste from `ws_read` and cannot be fixed +by stripping. The message shows a before/after; copy the span again without the numbers. ### `Edit refused — old_str is empty (or only whitespace)` @@ -48,6 +55,18 @@ An empty search used to pass `strings.Contains` and silently prepend `new_str`. the three real intents: create → `ws_write`; append → anchor on the last 2–3 lines; insert → repeat the anchor at the end of `new_str`. +### `Write refused — content is empty, so this would create as a 0-byte file` + +`ws_write` reads the file body from `content` (also `contents` / `text` / `body`). The usual cause is +the text sitting under a key the tool does not read (`code`, `source`, …) — the message lists any +unrecognized key in the call. Truncating an *existing* file to zero bytes needs `allow_shrink: true`. + +### `old_str is required` after a call that clearly had one + +Older builds matched argument names exactly. `ws_edit` now also accepts `old_string` / `search` / +`old` and `new_string` / `replace` / `new`; `ws_patch` accepts `diff` / `hunk`. The canonical key +wins when both are present. + ### `old_str found N times in ` The search text is not unique. Add 2–3 surrounding lines, or pass `replace_all: true` if you diff --git a/pkg/agents/factory.go b/pkg/agents/factory.go index 4d4345d..df5cb1f 100644 --- a/pkg/agents/factory.go +++ b/pkg/agents/factory.go @@ -25,13 +25,20 @@ type RoleSpec struct { Tools []string `json:"tools,omitempty"` MaxIter int `json:"max_iter"` Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Skills []string `json:"skills,omitempty"` - Custom bool `json:"custom"` - Override bool `json:"override,omitempty"` + // TemperatureSet marks Temperature as deliberate even when it is 0. The + // request encoding treats a zero temperature as unset (GoLangGraph + // substitutes the provider default, the OpenAI-compatible body omits the + // key), so a role pinned at 0 was sampled at the server default. Only a + // spec that sets this gets its zero emitted; roles that never set a + // temperature keep the unset behavior. + TemperatureSet bool `json:"temperature_set,omitempty"` + MaxTokens int `json:"max_tokens"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Skills []string `json:"skills,omitempty"` + Custom bool `json:"custom"` + Override bool `json:"override,omitempty"` // JSONOnly marks a role whose entire output is one JSON document. Factory // attaches constrained decoding (response_format / guided_json / GBNF) for @@ -62,12 +69,14 @@ func (s RoleSpec) Directives() backends.Directives { toolChoice = "auto" } return backends.Directives{ - Role: s.ID, - SchemaRole: s.SchemaRole, - JSONOnly: s.JSONOnly, - SerialTools: s.SerialTools, - StopSequences: s.StopSequences, - ToolChoice: toolChoice, + Role: s.ID, + SchemaRole: s.SchemaRole, + JSONOnly: s.JSONOnly, + SerialTools: s.SerialTools, + StopSequences: s.StopSequences, + ToolChoice: toolChoice, + Temperature: s.Temperature, + TemperatureSet: s.TemperatureSet, } } @@ -159,11 +168,11 @@ func specs(coding []string) []RoleSpec { {ID: "manager", Title: "Engineering manager (squad assembly)", Description: "Splits a query into parallel squads with disjoint ownership and a frozen interface contract.", SystemPrompt: PromptManager, Tools: nil, MaxIter: 3, Temperature: 0.15, MaxTokens: 2048, SchemaRole: schema.RoleSquads}, {ID: "composer", Title: "Dynamic pipeline composer", Description: "Assembles the right team, tools, and skills into a task-specific pipeline.", SystemPrompt: PromptComposer, Tools: nil, MaxIter: 3, Temperature: 0.2, MaxTokens: 2048, SchemaRole: schema.RoleComposition}, - // reviewer-strict is the second reviewer the speculative review race in - // pkg/loop has always asked for. Until it was registered here, - // SubAgentExecutor answered "subagent 'reviewer-strict' not found" and - // the documented second opinion never ran. - {ID: RoleReviewerStrict, Title: "Strict second reviewer", Description: "Second opinion on a task: approves only on complete, demonstrated evidence.", SystemPrompt: PromptReviewerStrict, Tools: nil, MaxIter: 2, Temperature: 0.0, MaxTokens: 768, SchemaRole: schema.RoleReview}, + // reviewer-strict is the sequential second opinion pkg/loop asks for + // when the primary reviewer returns no readable verdict. Until it was + // registered here, SubAgentExecutor answered "subagent 'reviewer-strict' + // not found" and the documented second opinion never ran. + {ID: RoleReviewerStrict, Title: "Strict second reviewer", Description: "Second opinion on a task: approves only on complete, demonstrated evidence.", SystemPrompt: PromptReviewerStrict, Tools: nil, MaxIter: 2, Temperature: 0.0, TemperatureSet: true, MaxTokens: 768, SchemaRole: schema.RoleReview}, // Architect/editor pair (Aider's measured decomposition win). The // describer reasons with no format constraints and no tools; the editor @@ -176,8 +185,8 @@ func specs(coding []string) []RoleSpec { // Built-in role ids added alongside the original 17-specialist roster. const ( - // RoleReviewerStrict is the second reviewer used by the speculative review - // race in pkg/loop when max_parallel >= 3. + // RoleReviewerStrict is the second reviewer pkg/loop asks, sequentially, + // only when the primary reviewer produced no readable verdict. RoleReviewerStrict = "reviewer-strict" // RoleDescriber is the prose half of the architect/editor pair. RoleDescriber = "describer" @@ -388,6 +397,13 @@ type Factory struct { // ModelProfiles resolves caps against each agent's effective model // (per-agent override ?? global stack/config model). ModelProfiles map[string]config.ModelProfile + // LiveElide is the live ReAct compaction policy: on every request a + // tool-using role sends, old tool results are elided deterministically + // once the transcript passes AtPercent of the model's context window. + // WindowTokens is resolved per role from the model profile when left 0. + // The zero value installs nothing; the orchestrator sets it from + // react_compact / react_compact_at_percent. See loop.LiveReactCompactionWired. + LiveElide backends.LiveElide // Optional global fallback caps when ModelProfiles is empty. ProfileMaxTokens int ProfileMaxTurns int @@ -558,7 +574,7 @@ func (f *Factory) AllSpecs() []RoleSpec { } // IsKnownRole reports whether id names a built-in specialist. Wire-up code that -// names a slot role (pkg/loop's speculative review race, pipeline phase +// names a slot role (pkg/loop's strict second-opinion reviewer, pipeline phase // bindings) should assert with this so a typo fails loudly at configuration // time instead of silently at runtime, the way "reviewer-strict" did for as // long as it went unregistered. @@ -755,7 +771,7 @@ func (f *Factory) definition(spec RoleSpec) *agent.BaseAgentDefinition { // response_format, stop, or tool_choice — but it does resolve the provider // by the name set here, which is the one hook the read-only dependency // leaves open. Everything downstream (orchestrator, loop) gets it for free. - cfg.Provider = backends.BindRole(f.LLM, cfg.Provider, spec.Directives()) + cfg.Provider = backends.BindRole(f.LLM, cfg.Provider, f.directivesFor(spec, cfg.Model)) cfg.SystemPrompt = spec.SystemPrompt cfg.Tools = spec.Tools cfg.Temperature = spec.Temperature @@ -804,6 +820,31 @@ func (f *Factory) definition(spec RoleSpec) *agent.BaseAgentDefinition { return def } +// directivesFor is the role's decoding contract plus, for a tool-using role, +// the live elision policy sized to the model it will actually run on. +func (f *Factory) directivesFor(spec RoleSpec, model string) backends.Directives { + d := spec.Directives() + d.LiveElide = f.liveElideFor(spec, model) + return d +} + +// liveElideFor sizes the live elision policy for one role. A tool-less role +// has no tool results to elide, and a role whose model has no known context +// window cannot be given a threshold, so both get the zero policy. +func (f *Factory) liveElideFor(spec RoleSpec, model string) backends.LiveElide { + if len(spec.Tools) == 0 || f.LiveElide.AtPercent <= 0 { + return backends.LiveElide{} + } + e := f.LiveElide + if e.WindowTokens <= 0 { + e.WindowTokens = config.ResolveModelProfile(f.ModelProfiles, model).ContextLimit + } + if !e.Enabled() { + return backends.LiveElide{} + } + return e +} + // isCodingRole classifies by BASE role. An escalated worker that stopped // matching here would silently lose its model_profile caps — same agent, wrong // token budget, and nothing in the output to say so. diff --git a/pkg/agents/factory_test.go b/pkg/agents/factory_test.go index 60617a7..587265d 100644 --- a/pkg/agents/factory_test.go +++ b/pkg/agents/factory_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/UnicoLab/slmcode/pkg/backends" "github.com/UnicoLab/slmcode/pkg/config" "github.com/UnicoLab/slmcode/pkg/plan" "github.com/UnicoLab/slmcode/pkg/schema" @@ -33,6 +34,65 @@ func TestSpecsRoster(t *testing.T) { } } +// TestStrictReviewerPinsTemperatureZeroDeliberately: reviewer-strict is the one +// built-in that asks for temperature 0, and 0 is the float zero value. Without +// an explicit "set" marker the request encoding dropped it and the strict +// reviewer sampled at the server default. The marker must reach the provider +// directives; roles that never set a temperature must not carry it. +func TestStrictReviewerPinsTemperatureZeroDeliberately(t *testing.T) { + var strict, reviewer *RoleSpec + for i := range Specs() { + switch Specs()[i].ID { + case RoleReviewerStrict: + s := Specs()[i] + strict = &s + case plan.RoleReviewer: + s := Specs()[i] + reviewer = &s + } + } + if strict == nil || reviewer == nil { + t.Fatal("roster is missing reviewer or reviewer-strict") + } + if strict.Temperature != 0 || !strict.TemperatureSet { + t.Fatalf("reviewer-strict temperature=%v set=%v, want 0 set deliberately", strict.Temperature, strict.TemperatureSet) + } + d := strict.Directives() + if !d.TemperatureSet || d.Temperature != 0 { + t.Fatalf("directives lost the pin: %+v", d) + } + if reviewer.TemperatureSet || reviewer.Directives().TemperatureSet { + t.Fatal("a role that never pinned its temperature is marked as having set one") + } +} + +// TestLiveElideIsSizedPerToolRole: the live elision policy reaches the +// provider directives of tool-using roles only, sized to the model's context +// window from the profile; tool-less roles and an unset policy get nothing. +func TestLiveElideIsSizedPerToolRole(t *testing.T) { + f := &Factory{ + LiveElide: backends.LiveElide{AtPercent: 80}, + ModelProfiles: map[string]config.ModelProfile{"default": {ContextLimit: 8192}}, + } + worker := RoleSpec{ID: plan.RoleWorker, Tools: []string{"ws_read", "ws_edit"}} + got := f.directivesFor(worker, "some-model").LiveElide + if !got.Enabled() || got.WindowTokens != 8192 || got.AtPercent != 80 { + t.Fatalf("worker policy = %+v, want enabled at 80%% of 8192", got) + } + if e := f.directivesFor(RoleSpec{ID: plan.RolePlanner}, "some-model").LiveElide; e.Enabled() { + t.Fatalf("a tool-less role was given live elision: %+v", e) + } + off := &Factory{ModelProfiles: f.ModelProfiles} + if e := off.directivesFor(worker, "some-model").LiveElide; e.Enabled() { + t.Fatalf("an unset policy was installed: %+v", e) + } + noWindow := &Factory{LiveElide: backends.LiveElide{AtPercent: 80}, + ModelProfiles: map[string]config.ModelProfile{"default": {}}} + if e := noWindow.directivesFor(worker, "some-model").LiveElide; e.Enabled() { + t.Fatalf("a model with no known window was given a threshold: %+v", e) + } +} + func TestCodingAgentsAllowFindModelsAndMCP(t *testing.T) { need := map[string]bool{"find_models": false, "mcp_call": false} for _, s := range Specs() { diff --git a/pkg/agents/workerprompt.go b/pkg/agents/workerprompt.go index 89e3998..685e55a 100644 --- a/pkg/agents/workerprompt.go +++ b/pkg/agents/workerprompt.go @@ -29,6 +29,44 @@ func WorkerScopeRules() string { "If ws_patch fails, re-read the file and retry a minimal SEARCH/REPLACE; never invent new root files.\n" } +// FocusFilesSection renders the HARD SCOPE block: the focus-file list and the +// scope rules that follow it, or "" when the task names no files. +// +// It is shared by the worker prompt and the corrector prompt on purpose. The +// scope gate grades both agents against the same list, so both must be shown +// the same list — the corrector used to be given only the review issues and a +// clip of the previous output, and had to infer its scope from prose. +func FocusFilesSection(files []string) string { + if len(files) == 0 { + return "" + } + return "\n## Focus files (HARD SCOPE)\nOnly edit these paths or files in the same package directory:\n- " + + strings.Join(files, "\n- ") + "\n" + WorkerScopeRules() +} + +// Scoped-pack markers. The orchestrator prepends context.TaskPack.Render() — +// which begins with ScopedPackHeader — to a task's description and separates +// it from the task's own text with TaskInstructionsHeader. +const ( + ScopedPackHeader = "# Scoped context" + TaskInstructionsHeader = "## Task instructions" +) + +// SplitScopedPack separates a description that begins with a scoped context +// pack into the pack and the task instructions. A description without a pack +// comes back whole as the body with an empty pack. +func SplitScopedPack(desc string) (pack, body string) { + trimmed := strings.TrimSpace(desc) + if !strings.HasPrefix(trimmed, ScopedPackHeader) { + return "", desc + } + idx := strings.Index(trimmed, TaskInstructionsHeader) + if idx < 0 { + return "", desc + } + return strings.TrimSpace(trimmed[:idx]), strings.TrimSpace(trimmed[idx+len(TaskInstructionsHeader):]) +} + // WorkerTaskRules is the canonical "## Required finish" block for an // implementation role, with a language-appropriate smoke command. // @@ -93,34 +131,52 @@ type WorkerPromptOptions struct { Description string } -// BuildWorkerPrompt renders the full task-adjacent worker prompt: task -// identity, language, body, hard-scoped focus files, acceptance, checklist, -// human notes and the required-finish rules for the task's role. +// BuildWorkerPrompt renders the full task-adjacent worker prompt: scoped +// context pack (when the description carries one), language, task identity, +// body, hard-scoped focus files, acceptance, checklist, human notes and the +// required-finish rules for the task's role. // // Both the inner loop's fallback and the orchestrator's production builder // should go through this, so a rule can never again exist in the gate but not // in the prompt. +// +// Order is load-bearing for KV-cache reuse. The scoped pack is rendered +// most-stable-first (skills, docs, repo map, then file bodies) and is byte +// identical across every task that shares it — but the prompt used to open +// with "ID: T3 / Title: … / Column: …", which differs per task, so the shared +// prefix was a few dozen bytes long and the whole pack was re-prefilled for +// every task. The pack now comes first and the task header sits immediately +// before the task's own instructions; the Column line, which changed between +// attempts at the same task, is gone. func BuildWorkerPrompt(t plan.Task, opt WorkerPromptOptions) string { desc := opt.Description if strings.TrimSpace(desc) == "" { desc = t.Description } + pack, body := SplitScopedPack(desc) + lang := "" + if h := strings.TrimSpace(opt.LangHint); h != "" { + lang = "## Project language\n" + h + "\n\n" + } var b strings.Builder + if pack != "" { + // Stable prefix first: the pack, then the project-wide language line. + b.WriteString(pack) + b.WriteString("\n\n") + b.WriteString(lang) + } b.WriteString("Atomic task — complete only this:\n\n") - fmt.Fprintf(&b, "ID: %s\nTitle: %s\nColumn: %s\nRole: %s\n\n", t.ID, t.Title, t.Column, t.Role) - if h := strings.TrimSpace(opt.LangHint); h != "" { - b.WriteString("## Project language\n" + h + "\n\n") + fmt.Fprintf(&b, "ID: %s\nTitle: %s\nRole: %s\n\n", t.ID, t.Title, t.Role) + if pack != "" { + b.WriteString(TaskInstructionsHeader + "\n\n") + } else { + b.WriteString(lang) } - b.WriteString(desc) + b.WriteString(body) b.WriteString("\n") - if len(t.Files) > 0 { - b.WriteString("\n## Focus files (HARD SCOPE)\nOnly edit these paths or files in the same package directory:\n- ") - b.WriteString(strings.Join(t.Files, "\n- ")) - b.WriteString("\n") - b.WriteString(WorkerScopeRules()) - } + b.WriteString(FocusFilesSection(t.Files)) if strings.TrimSpace(t.Acceptance) != "" { b.WriteString("\nAcceptance criteria:\n") b.WriteString(t.Acceptance) diff --git a/pkg/agents/workerprompt_test.go b/pkg/agents/workerprompt_test.go index 8326007..25de0b3 100644 --- a/pkg/agents/workerprompt_test.go +++ b/pkg/agents/workerprompt_test.go @@ -49,6 +49,81 @@ func TestAWorkerKeepsTheWorkerFinishContract(t *testing.T) { } } +// ── The scoped pack is the shared byte prefix ───────────────────────────── +// +// context.TaskPack.Render is written most-stable-first so that every task +// sharing a pack shares a KV-cache prefix. The prompt used to open with +// "ID: T1 / Title: … / Column: …" BEFORE the pack, so the shared prefix ended +// at "Atomic task — complete only this:\n\nID: T" and the whole pack was +// re-prefilled for every task. + +func TestTasksSharingAPackShareItAsABytePrefix(t *testing.T) { + pack := "# Scoped context for role=worker\n\n## Doc: README\n\nUse the store.\n\n" + + "## File: internal/store.go\n\n```go\npackage store\n```" + mk := func(id, title, body string) plan.Task { + return plan.Task{ + ID: id, Title: title, Role: plan.RoleWorker, Column: plan.ColInProgress, + Description: pack + "\n## Task instructions\n\n" + body, + Files: []string{"internal/store.go"}, + Acceptance: "go test ./... passes", + } + } + opt := WorkerPromptOptions{LangHint: "Project language: Go."} + a := BuildWorkerPrompt(mk("T1", "add List", "Add List to the store."), opt) + b := BuildWorkerPrompt(mk("T2", "add Delete", "Add Delete to the store."), opt) + + common := 0 + for common < len(a) && common < len(b) && a[common] == b[common] { + common++ + } + if common < len(pack) { + t.Fatalf("shared byte prefix is %d bytes; the %d-byte pack must be inside it:\n%q", common, len(pack), a[:common]) + } + if !strings.HasPrefix(a, "# Scoped context for role=worker") { + t.Errorf("prompt does not open with the pack:\n%s", a[:120]) + } + // The language line is project-wide, so it belongs to the shared prefix too. + if !strings.Contains(a[:common], "## Project language\nProject language: Go.") { + t.Error("the language hint is outside the shared prefix") + } + // The task header sits immediately before the task's own instructions. + header := strings.Index(a, "Atomic task — complete only this:\n\nID: T1\nTitle: add List\nRole: worker\n\n## Task instructions\n\nAdd List to the store.") + if header < 0 { + t.Errorf("task header is not immediately before the instructions:\n%s", a) + } + if strings.Contains(a, "Column:") { + t.Error("the Column line varies between attempts and must not be in the prompt") + } + // Nothing the gates enforce was lost in the reorder. + for _, want := range []string{"## Focus files (HARD SCOPE)", "internal/store.go", "Acceptance criteria:", "## Required finish", `"files_changed"`} { + if !strings.Contains(a, want) { + t.Errorf("prompt lost %q", want) + } + } +} + +// A description without a pack keeps the historical shape: header first. +func TestPromptWithoutAPackOpensWithTheTaskHeader(t *testing.T) { + task := plan.Task{ID: "T1", Title: "x", Role: plan.RoleWorker, Description: "Do it.", Files: []string{"a.go"}} + got := BuildWorkerPrompt(task, WorkerPromptOptions{LangHint: "Project language: Go."}) + if !strings.HasPrefix(got, "Atomic task — complete only this:\n\nID: T1\nTitle: x\nRole: worker\n\n## Project language\nProject language: Go.\n\nDo it.\n") { + t.Errorf("unexpected shape:\n%s", got) + } + if strings.Contains(got, "## Task instructions") { + t.Error("a pack-less prompt grew a Task instructions heading") + } +} + +func TestSplitScopedPack(t *testing.T) { + pack, body := SplitScopedPack("# Scoped context for role=worker\n\nbig pack\n\n## Task instructions\n\nDo the thing\n") + if pack != "# Scoped context for role=worker\n\nbig pack" || body != "Do the thing" { + t.Fatalf("pack=%q body=%q", pack, body) + } + if pack, body := SplitScopedPack("plain description"); pack != "" || body != "plain description" { + t.Fatalf("pack-less split: pack=%q body=%q", pack, body) + } +} + func tail(s string) string { if len(s) < 600 { return s diff --git a/pkg/backends/live_elide.go b/pkg/backends/live_elide.go new file mode 100644 index 0000000..7130c84 --- /dev/null +++ b/pkg/backends/live_elide.go @@ -0,0 +1,197 @@ +package backends + +import ( + "context" + "strings" + "sync" + + "github.com/UnicoLab/slmcode/pkg/compact" + "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" +) + +// Live ReAct compaction: deterministic elision of old tool results. +// +// A ReAct iteration is exactly one llm.Provider completion, and the roles are +// already bound to their own provider registrations (BindRole). So the one +// place in the process that sees iteration N of a 16-iteration worker — with +// its whole transcript — is a provider wrapper, the same route live token +// streaming took (stream.go). This file is that wrapper. It sits OUTERMOST in +// the role's stack, above the structured wrapper, so the elided transcript is +// what both the delegate and the direct constrained-decoding call send: +// +// liveElideProvider (role, THIS FILE) +// └── structuredProvider (role, constrained decoding) +// └── streamTeeProvider (role, live tokens) +// └── retryProvider → raw provider +// +// It does ONE thing, and only when the estimated transcript is over the +// threshold: the content of every tool RESULT but the last KeepLast is +// replaced with a placeholder. Every tool CALL stays, every tool_call_id pair +// stays, the leading system message and every user/assistant turn stay — a +// transcript that was legal before is legal after. Nothing is summarized: the +// checkpoint/resume path (pkg/loop.maybeCompactReact) may fold the head into a +// system digest, but on a LIVE request the head is the role's tool contract +// and dropping it mid-call would break the agent more reliably than a long +// context does. Deterministic elision costs no inference and is measured to +// beat summarization, which is also why the checkpoint path tries it first. +// +// The rewrite is per request and never touches the agent's own conversation, +// so it is repeatable: iteration N+1 sends the full transcript again and is +// elided the same way, and the elided prefix is byte-identical between +// iterations until the keep window slides — KV-cache reuse survives. + +// LiveElide configures the wrapper for one role. The zero value disables it. +type LiveElide struct { + // WindowTokens is the model's context window in tokens; <=0 disables. + WindowTokens int + // AtPercent triggers elision when the estimated transcript reaches this + // percentage of WindowTokens; <=0 or >=100 disables. + AtPercent int + // KeepLast is how many trailing tool results stay verbatim; <=0 means + // compact.DefaultElideKeepLast. + KeepLast int +} + +// Enabled reports whether this configuration elides anything at all. +func (e LiveElide) Enabled() bool { + return e.WindowTokens > 0 && e.AtPercent > 0 && e.AtPercent < 100 +} + +func (e LiveElide) keep() int { + if e.KeepLast > 0 { + return e.KeepLast + } + return compact.DefaultElideKeepLast +} + +type liveElideProvider struct { + inner llm.Provider + role string + cfg LiveElide +} + +// newLiveElide wraps p, or returns it unchanged when there is nothing to do. +func newLiveElide(p llm.Provider, role string, cfg LiveElide) llm.Provider { + if p == nil || !cfg.Enabled() { + return p + } + return &liveElideProvider{inner: p, role: strings.TrimSpace(role), cfg: cfg} +} + +// requestBytes approximates the prompt the request will render: the system +// prompt plus every message's text and tool-call payload. +func requestBytes(req llm.CompletionRequest) int { + n := len(req.SystemPrompt) + for _, m := range req.Messages { + n += len(m.Role) + len(m.Content) + len(m.Name) + len(m.ToolCallID) + for _, tc := range m.ToolCalls { + n += len(tc.Function.Name) + len(tc.Function.Arguments) + } + } + return n +} + +// elide returns req with old tool results collapsed when the transcript is +// over the threshold, and req unchanged otherwise. +func (p *liveElideProvider) elide(req llm.CompletionRequest) llm.CompletionRequest { + tokens := compact.EstimateTokens(requestBytes(req)) + if compact.UsagePercent(tokens, p.cfg.WindowTokens) < float64(p.cfg.AtPercent) { + return req + } + out, n := compact.ElideOldToolResultsFunc(req.Messages, p.cfg.keep(), compact.DefaultElidedPlaceholder, + func(m llm.Message) bool { return strings.EqualFold(m.Role, compact.RoleTool) }, + func(m llm.Message, placeholder string) llm.Message { m.Content = placeholder; return m }) + if n == 0 { + return req + } + recordElided(p.role, n) + req.Messages = out + return req +} + +func (p *liveElideProvider) GetName() string { return p.inner.GetName() } + +func (p *liveElideProvider) GetModels(ctx context.Context) ([]string, error) { + return p.inner.GetModels(ctx) +} + +func (p *liveElideProvider) Complete(ctx context.Context, req llm.CompletionRequest) (*llm.CompletionResponse, error) { + return p.inner.Complete(ctx, p.elide(req)) +} + +func (p *liveElideProvider) CompleteWithMode( + ctx context.Context, req llm.CompletionRequest, mode llm.StreamMode, +) (*llm.CompletionResponse, error) { + return p.inner.CompleteWithMode(ctx, p.elide(req), mode) +} + +func (p *liveElideProvider) CompleteStream( + ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, +) error { + return p.inner.CompleteStream(ctx, p.elide(req), cb) +} + +func (p *liveElideProvider) CompleteStreamWithMode( + ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, mode llm.StreamMode, +) error { + return p.inner.CompleteStreamWithMode(ctx, p.elide(req), cb, mode) +} + +func (p *liveElideProvider) IsHealthy(ctx context.Context) error { return p.inner.IsHealthy(ctx) } + +func (p *liveElideProvider) GetConfig() map[string]interface{} { + c := p.inner.GetConfig() + if c == nil { + c = map[string]interface{}{} + } + c["slmcode_live_elide"] = true + c["slmcode_live_elide_window_tokens"] = p.cfg.WindowTokens + c["slmcode_live_elide_at_percent"] = p.cfg.AtPercent + return c +} + +func (p *liveElideProvider) SetConfig(c map[string]interface{}) error { return p.inner.SetConfig(c) } +func (p *liveElideProvider) SupportsStreaming() bool { return p.inner.SupportsStreaming() } +func (p *liveElideProvider) GetStreamingConfig() *llm.StreamingConfig { + return p.inner.GetStreamingConfig() +} + +func (p *liveElideProvider) SetStreamingConfig(c *llm.StreamingConfig) error { + return p.inner.SetStreamingConfig(c) +} +func (p *liveElideProvider) Close() error { return p.inner.Close() } + +// Elision telemetry: how many tool results each role has had collapsed on live +// requests. Diagnostics read it the way they read MechanismStats. +var elideStats = struct { + mu sync.Mutex + byRole map[string]int +}{byRole: map[string]int{}} + +func recordElided(role string, n int) { + if n <= 0 { + return + } + elideStats.mu.Lock() + defer elideStats.mu.Unlock() + elideStats.byRole[role] += n +} + +// LiveElideStats returns, per role, how many tool results live elision has +// collapsed so far in this process. +func LiveElideStats() map[string]int { + elideStats.mu.Lock() + defer elideStats.mu.Unlock() + out := make(map[string]int, len(elideStats.byRole)) + for k, v := range elideStats.byRole { + out[k] = v + } + return out +} + +// ResetLiveElideStats clears the elision counters (tests). +func ResetLiveElideStats() { + elideStats.mu.Lock() + defer elideStats.mu.Unlock() + elideStats.byRole = map[string]int{} +} diff --git a/pkg/backends/live_elide_test.go b/pkg/backends/live_elide_test.go new file mode 100644 index 0000000..ded07dd --- /dev/null +++ b/pkg/backends/live_elide_test.go @@ -0,0 +1,202 @@ +package backends + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "github.com/UnicoLab/slmcode/pkg/compact" + "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" +) + +// recordingProvider captures every request the wrapper hands down. +type recordingProvider struct { + *fakeStreamProvider + mu sync.Mutex + reqs []llm.CompletionRequest +} + +func newRecordingProvider() *recordingProvider { + return &recordingProvider{fakeStreamProvider: &fakeStreamProvider{ + name: "rec", model: "m", chunks: []string{"ok"}, + }} +} + +func (p *recordingProvider) record(req llm.CompletionRequest) { + p.mu.Lock() + p.reqs = append(p.reqs, req) + p.mu.Unlock() +} + +func (p *recordingProvider) last() llm.CompletionRequest { + p.mu.Lock() + defer p.mu.Unlock() + return p.reqs[len(p.reqs)-1] +} + +func (p *recordingProvider) Complete(ctx context.Context, req llm.CompletionRequest) (*llm.CompletionResponse, error) { + p.record(req) + return p.fakeStreamProvider.Complete(ctx, req) +} + +func (p *recordingProvider) CompleteWithMode(ctx context.Context, req llm.CompletionRequest, mode llm.StreamMode) (*llm.CompletionResponse, error) { + p.record(req) + return p.fakeStreamProvider.CompleteWithMode(ctx, req, mode) +} + +func (p *recordingProvider) CompleteStream(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback) error { + p.record(req) + return p.fakeStreamProvider.CompleteStream(ctx, req, cb) +} + +func (p *recordingProvider) CompleteStreamWithMode(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, mode llm.StreamMode) error { + p.record(req) + return p.fakeStreamProvider.CompleteStreamWithMode(ctx, req, cb, mode) +} + +// liveTranscript is a ReAct transcript: system prompt, the task, then n tool +// pairs whose results are `size` bytes each. +func liveTranscript(n, size int) []llm.Message { + msgs := []llm.Message{ + {Role: "system", Content: "You are the worker. Tools: ws_read, ws_edit."}, + {Role: "user", Content: "implement the thing"}, + } + for i := 0; i < n; i++ { + id := fmt.Sprintf("call_%d", i) + msgs = append(msgs, + llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{{ + ID: id, Type: "function", + Function: llm.FunctionCall{Name: "ws_read", Arguments: fmt.Sprintf(`{"path":"f%d.go"}`, i)}, + }}}, + llm.Message{Role: "tool", ToolCallID: id, Name: "ws_read", Content: strings.Repeat("x", size)}, + ) + } + return msgs +} + +func toolResults(msgs []llm.Message) []llm.Message { + var out []llm.Message + for _, m := range msgs { + if m.Role == "tool" { + out = append(out, m) + } + } + return out +} + +// TestLiveElideCollapsesOldToolResultsOverThreshold: over the threshold, every +// tool result but the last five is replaced with the placeholder — and nothing +// else in the transcript moves. Under it, the request goes through untouched. +func TestLiveElideCollapsesOldToolResultsOverThreshold(t *testing.T) { + ResetLiveElideStats() + inner := newRecordingProvider() + // 2000-token window, elide at 50% → 1000 tokens ≈ 4000 chars. + p := newLiveElide(inner, "worker", LiveElide{WindowTokens: 2000, AtPercent: 50}) + + big := liveTranscript(8, 1000) + if _, err := p.CompleteWithMode(context.Background(), llm.CompletionRequest{ + Messages: big, Model: "m", + }, llm.StreamModeNone); err != nil { + t.Fatal(err) + } + got := inner.last().Messages + if len(got) != len(big) { + t.Fatalf("elision changed the message count: %d → %d", len(big), len(got)) + } + if got[0].Role != "system" || got[0].Content != big[0].Content { + t.Fatalf("the leading system message was touched: %+v", got[0]) + } + if got[1].Role != "user" || got[1].Content != big[1].Content { + t.Fatalf("the user turn was touched: %+v", got[1]) + } + results := toolResults(got) + if len(results) != 8 { + t.Fatalf("tool results = %d, want 8", len(results)) + } + for i, m := range results { + wantID := fmt.Sprintf("call_%d", i) + if m.ToolCallID != wantID { + t.Errorf("result %d lost its tool_call_id: %q", i, m.ToolCallID) + } + elided := m.Content == compact.DefaultElidedPlaceholder + if i < 3 && !elided { + t.Errorf("old result %d was kept verbatim (%d bytes)", i, len(m.Content)) + } + if i >= 3 && elided { + t.Errorf("one of the last five results (%d) was elided", i) + } + } + calls := 0 + for i, m := range got { + if m.Role == "assistant" { + calls += len(m.ToolCalls) + if len(m.ToolCalls) != 1 || m.ToolCalls[0].ID != big[i].ToolCalls[0].ID { + t.Errorf("assistant tool call at %d was changed: %+v", i, m) + } + } + } + if calls != 8 { + t.Fatalf("tool calls = %d, want 8 (elision must never drop a call)", calls) + } + if n := LiveElideStats()["worker"]; n != 3 { + t.Fatalf("elision telemetry = %d, want 3", n) + } + // The agent's own slice is not mutated: the next iteration re-sends the + // full transcript and is elided the same way. + if big[2*1+2].Content == compact.DefaultElidedPlaceholder { + t.Fatal("the caller's transcript was mutated in place") + } + + small := liveTranscript(2, 100) + if _, err := p.Complete(context.Background(), llm.CompletionRequest{Messages: small}); err != nil { + t.Fatal(err) + } + for i, m := range inner.last().Messages { + if m.Role != small[i].Role || m.Content != small[i].Content || m.ToolCallID != small[i].ToolCallID { + t.Fatalf("an under-threshold transcript was rewritten at %d: %+v", i, m) + } + } + if n := LiveElideStats()["worker"]; n != 3 { + t.Fatalf("an under-threshold request was counted: %d", n) + } +} + +// TestLiveElideIsAPassThroughWhenDisabled: the zero policy installs nothing. +func TestLiveElideIsAPassThroughWhenDisabled(t *testing.T) { + inner := newRecordingProvider() + for _, cfg := range []LiveElide{{}, {WindowTokens: 8192}, {AtPercent: 80}, {WindowTokens: 8192, AtPercent: 100}} { + if got := newLiveElide(inner, "worker", cfg); got != llm.Provider(inner) { + t.Errorf("%+v installed a wrapper", cfg) + } + } +} + +// TestBindRoleInstallsLiveElideOutermost: a role bound with a LiveElide policy +// gets the wrapper on top of its stack; a role without one does not. +func TestBindRoleInstallsLiveElideOutermost(t *testing.T) { + m := llm.NewProviderManager() + if err := m.RegisterProvider("base", newRecordingProvider()); err != nil { + t.Fatal(err) + } + key := BindRole(m, "base", Directives{ + Role: "worker", SerialTools: true, ToolChoice: "auto", + LiveElide: LiveElide{WindowTokens: 8192, AtPercent: 80}, + }) + p, err := m.GetProvider(key) + if err != nil { + t.Fatalf("role not registered: %v", err) + } + if p.GetConfig()["slmcode_live_elide"] != true { + t.Fatalf("live elision is not on the role's stack: %v", p.GetConfig()) + } + if _, outer := p.(*liveElideProvider); !outer { + t.Fatalf("the elision wrapper is not outermost: %T", p) + } + plainKey := BindRole(m, "base", Directives{Role: "planner", JSONOnly: true}) + plain, _ := m.GetProvider(plainKey) + if _, has := plain.GetConfig()["slmcode_live_elide"]; has { + t.Fatal("a role with no policy was given the wrapper") + } +} diff --git a/pkg/backends/probetimeout_test.go b/pkg/backends/probetimeout_test.go index 562c1b1..7f3c2ed 100644 --- a/pkg/backends/probetimeout_test.go +++ b/pkg/backends/probetimeout_test.go @@ -25,20 +25,25 @@ import ( // structured role on that endpoint degraded to prompt-only + repair for a week. func TestSlowEndpointDoesNotPoisonCapabilitiesForAWeek(t *testing.T) { var calls int64 - // Every request costs 100ms — a stand-in for a model still loading weights. - // The first one fits inside the budget and succeeds; the rest cannot. + restore := ProbeTimeout + ProbeTimeout = 150 * time.Millisecond + defer func() { ProbeTimeout = restore }() + + // The first request answers at once — it must succeed whatever the load on + // the test machine — and every later one sleeps past the shared deadline: a + // stand-in for a model that answered the plain probe and then went off to + // load weights. (An earlier version slept 100ms on every request against + // the 150ms budget; under a busy `go test ./...` the first request alone + // could miss the window and the test failed for the wrong reason.) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt64(&calls, 1) - time.Sleep(100 * time.Millisecond) + if atomic.AddInt64(&calls, 1) > 1 { + time.Sleep(2 * ProbeTimeout) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) })) defer srv.Close() - restore := ProbeTimeout - ProbeTimeout = 150 * time.Millisecond - defer func() { ProbeTimeout = restore }() - // runProbe directly: Probe() memoises per key and would hide a second call. got := runProbe(context.Background(), "omlx", srv.URL, "slow-model", "k") diff --git a/pkg/backends/retry.go b/pkg/backends/retry.go index bd04494..ec1d868 100644 --- a/pkg/backends/retry.go +++ b/pkg/backends/retry.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" @@ -107,6 +108,12 @@ func Classify(err error) Classification { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return Classification{Class: ClassCanceled} } + // A stream that already delivered output cannot be replayed: whatever the + // underlying failure was, another attempt would show the prefix twice. + var pse *partialStreamError + if errors.As(err, &pse) { + return Classification{Class: ClassPermanent} + } var he *HTTPError if errors.As(err, &he) { return classifyStatus(he.Status, he.Body, he.RetryAfter) @@ -547,18 +554,64 @@ func (p *retryProvider) CompleteWithMode(ctx context.Context, req llm.Completion }) } -// CompleteStream is not retried: a partially delivered stream cannot be -// replayed without the callback seeing the prefix twice. +// CompleteStream retries exactly like Complete UNTIL the first delta reaches +// the callback. It used to bypass retryDo entirely, on the grounds that a +// partially delivered stream cannot be replayed — true, but the ReAct loop +// takes this path whenever a token sink is attached (TUI, Studio), so a 429 +// or a connection refused by a model server that was still loading got zero +// attempts precisely when someone was watching. A failure before any delta is +// indistinguishable from a failed Complete and is retried the same way; once +// a delta has been delivered the error is surfaced as-is. func (p *retryProvider) CompleteStream(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback) error { - cctx, cancel := p.callCtx(ctx, req) - defer cancel() - return p.inner.CompleteStream(cctx, req, cb) + return p.streamWithRetry(ctx, req, cb, func(c context.Context, wrapped llm.StreamCallback) error { + return p.inner.CompleteStream(c, req, wrapped) + }) } func (p *retryProvider) CompleteStreamWithMode(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, mode llm.StreamMode) error { - cctx, cancel := p.callCtx(ctx, req) - defer cancel() - return p.inner.CompleteStreamWithMode(cctx, req, cb, mode) + return p.streamWithRetry(ctx, req, cb, func(c context.Context, wrapped llm.StreamCallback) error { + return p.inner.CompleteStreamWithMode(c, req, wrapped, mode) + }) +} + +// partialStreamError marks a failure that happened after at least one delta +// was delivered. Classify treats it as permanent so retryDo surfaces it +// immediately; Unwrap keeps the original error visible to callers. +type partialStreamError struct{ err error } + +func (e *partialStreamError) Error() string { return e.err.Error() } +func (e *partialStreamError) Unwrap() error { return e.err } + +// streamWithRetry drives one streaming call per attempt through retryDo. Each +// attempt gets a fresh deadline (callCtx) and a fresh delivered flag; the +// caller's callback only ever sees deltas from the attempt that produced +// output, so nothing is replayed. +func (p *retryProvider) streamWithRetry( + ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, + call func(ctx context.Context, cb llm.StreamCallback) error, +) error { + _, err := retryDo(ctx, p.policy, func(ctx context.Context, _ int) (struct{}, error) { + cctx, cancel := p.callCtx(ctx, req) + defer cancel() + var delivered atomic.Bool + wrapped := func(chunk llm.CompletionResponse) error { + delivered.Store(true) + if cb == nil { + return nil + } + return cb(chunk) + } + err := call(cctx, wrapped) + if err != nil && delivered.Load() { + return struct{}{}, &partialStreamError{err: err} + } + return struct{}{}, err + }) + var pse *partialStreamError + if errors.As(err, &pse) { + return pse.err + } + return err } func (p *retryProvider) IsHealthy(ctx context.Context) error { return p.inner.IsHealthy(ctx) } diff --git a/pkg/backends/retry_stream_test.go b/pkg/backends/retry_stream_test.go new file mode 100644 index 0000000..d274258 --- /dev/null +++ b/pkg/backends/retry_stream_test.go @@ -0,0 +1,151 @@ +package backends + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/workspace" + "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" +) + +// flakyStreamProvider fails its first failBefore stream calls with failErr +// BEFORE delivering anything, and (when failAfterDelta is set) fails every +// call with failErr right AFTER delivering one delta. +type flakyStreamProvider struct { + fakeStreamProvider + failBefore int + failAfterDelta bool + failErr error + mu2 sync.Mutex + calls int +} + +func (p *flakyStreamProvider) CompleteStream(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback) error { + p.mu2.Lock() + p.calls++ + n := p.calls + p.mu2.Unlock() + if n <= p.failBefore { + return p.failErr + } + if p.failAfterDelta { + _ = cb(llm.CompletionResponse{Model: p.model, + Choices: []llm.Choice{{Delta: llm.Message{Role: "assistant", Content: "partial"}}}}) + return p.failErr + } + return p.fakeStreamProvider.CompleteStream(ctx, req, cb) +} + +func (p *flakyStreamProvider) CompleteStreamWithMode(ctx context.Context, req llm.CompletionRequest, cb llm.StreamCallback, _ llm.StreamMode) error { + return p.CompleteStream(ctx, req, cb) +} + +func (p *flakyStreamProvider) CompleteWithMode(ctx context.Context, req llm.CompletionRequest, mode llm.StreamMode) (*llm.CompletionResponse, error) { + if mode == llm.StreamModeNone { + return p.Complete(ctx, req) + } + return llm.CollectStream(ctx, p.CompleteStream, req) +} + +func (p *flakyStreamProvider) streamCallCount() int { + p.mu2.Lock() + defer p.mu2.Unlock() + return p.calls +} + +func fastPolicy() RetryPolicy { + return RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond} +} + +// A transient failure BEFORE any delta is retried on the streaming path, the +// one the ReAct loop takes whenever a token sink (TUI/Studio) is attached. +func TestStreamRetriesTransientFailureBeforeFirstDelta(t *testing.T) { + inner := &flakyStreamProvider{ + fakeStreamProvider: fakeStreamProvider{name: "fake", model: "m", chunks: []string{"hel", "lo"}}, + failBefore: 2, + failErr: errors.New("error, status code: 429, message: rate limit"), + } + p := NewRetryProvider(inner, "fake", "m", fastPolicy()) + + var got strings.Builder + err := p.CompleteStream(context.Background(), llm.CompletionRequest{Model: "m"}, func(c llm.CompletionResponse) error { + for _, ch := range c.Choices { + got.WriteString(ch.Delta.Content) + } + return nil + }) + if err != nil { + t.Fatalf("stream failed after retries: %v", err) + } + if inner.streamCallCount() != 3 { + t.Fatalf("stream attempts = %d, want 3 (two 429s then success)", inner.streamCallCount()) + } + if got.String() != "hello" { + t.Fatalf("callback saw %q — deltas must come from the successful attempt only", got.String()) + } + + // Through the tee (the real stack: tee over retry), with a sink attached. + inner2 := &flakyStreamProvider{ + fakeStreamProvider: fakeStreamProvider{name: "fake", model: "m", chunks: []string{"a", "b"}}, + failBefore: 1, + failErr: errors.New("dial tcp 127.0.0.1:8080: connect: connection refused"), + } + stack := newStreamTee(NewRetryProvider(inner2, "fake", "m", fastPolicy()), "worker") + rec := &recorder{} + unregister := RegisterTokenSink("worker", "T1", rec.sink) + defer unregister() + ctx := workspace.WithTaskID(context.Background(), "T1") + resp, err := stack.CompleteWithMode(ctx, llm.CompletionRequest{Model: "m", Stream: true}, llm.StreamModeForced) + if err != nil { + t.Fatalf("teed stream failed after retry: %v", err) + } + if inner2.streamCallCount() != 2 { + t.Fatalf("teed stream attempts = %d, want 2", inner2.streamCallCount()) + } + if resp == nil || len(resp.Choices) == 0 || resp.Choices[0].Message.Content != "ab" { + t.Fatalf("teed response: %+v", resp) + } + if joined := rec.text(); joined != "ab" { + t.Fatalf("sink saw %q, want the successful attempt's deltas only", joined) + } +} + +// Once a delta has been delivered the stream cannot be replayed: the error is +// surfaced as-is, with no second attempt. +func TestStreamDoesNotRetryAfterFirstDelta(t *testing.T) { + failErr := errors.New("error, status code: 503, message: upstream reset") + inner := &flakyStreamProvider{ + fakeStreamProvider: fakeStreamProvider{name: "fake", model: "m", chunks: []string{"x"}}, + failAfterDelta: true, + failErr: failErr, + } + p := NewRetryProvider(inner, "fake", "m", fastPolicy()) + deltas := 0 + err := p.CompleteStream(context.Background(), llm.CompletionRequest{Model: "m"}, func(llm.CompletionResponse) error { + deltas++ + return nil + }) + if err == nil || !errors.Is(err, failErr) { + t.Fatalf("error after partial stream: %v (want the original)", err) + } + if inner.streamCallCount() != 1 { + t.Fatalf("stream attempts = %d, want 1 (no replay after a delivered delta)", inner.streamCallCount()) + } + if deltas != 1 { + t.Fatalf("callback saw %d deltas, want exactly the one delivered", deltas) + } + // Permanent failures before any delta are still tried once. + inner3 := &flakyStreamProvider{ + fakeStreamProvider: fakeStreamProvider{name: "fake", model: "m", chunks: []string{"x"}}, + failBefore: 5, + failErr: errors.New("error, status code: 400, message: bad request"), + } + _ = NewRetryProvider(inner3, "fake", "m", fastPolicy()).CompleteStream(context.Background(), llm.CompletionRequest{Model: "m"}, nil) + if inner3.streamCallCount() != 1 { + t.Fatalf("permanent failure attempts = %d, want 1", inner3.streamCallCount()) + } +} diff --git a/pkg/backends/structured.go b/pkg/backends/structured.go index 5ede661..8bbae96 100644 --- a/pkg/backends/structured.go +++ b/pkg/backends/structured.go @@ -39,6 +39,19 @@ type Directives struct { StopSequences []string // ToolChoice is passed through as `tool_choice` (normally "auto"). ToolChoice string + // Temperature is the role's sampling temperature, meaningful only when + // TemperatureSet is true. It exists because the request encoding treats a + // zero temperature as UNSET: GoLangGraph substitutes the provider's default + // and the OpenAI-compatible body omits the key, so a role that deliberately + // asked for 0 (reviewer-strict) was sampled at the server default instead. + // The structured path always emits `temperature` for a role that set one. + Temperature float64 + TemperatureSet bool + // LiveElide is the live ReAct compaction policy for this role: old tool + // results are elided deterministically on every request once the + // transcript passes the threshold. The zero value installs nothing. See + // live_elide.go. + LiveElide LiveElide } // backendMeta is what the direct structured path needs to talk to a server on @@ -124,6 +137,9 @@ func BindRole(m *llm.ProviderManager, baseKey string, d Directives) string { client: &http.Client{}, } } + // Outermost: the elided transcript must be what BOTH the delegate and the + // structured wrapper's direct constrained-decoding call send. + p = newLiveElide(p, d.Role, d.LiveElide) if err := m.RegisterProvider(key, p); err != nil { // A concurrent Create won the race — reuse whatever landed. if _, gerr := m.GetProvider(key); gerr == nil { @@ -386,8 +402,14 @@ func (p *structuredProvider) buildBody(req llm.CompletionRequest, spec schema.Sp "messages": msgs, "stream": false, } - if req.Temperature > 0 { + // A zero temperature is only "unset" for a role that never set one. A role + // whose spec pinned it — reviewer-strict at 0 — gets the key regardless, + // or the server samples at its own default and the pin was fiction. + switch { + case req.Temperature > 0: body["temperature"] = req.Temperature + case p.directives.TemperatureSet: + body["temperature"] = p.directives.Temperature } if req.MaxTokens > 0 { body["max_tokens"] = req.MaxTokens diff --git a/pkg/backends/structured_test.go b/pkg/backends/structured_test.go index 7fc2d94..f92376e 100644 --- a/pkg/backends/structured_test.go +++ b/pkg/backends/structured_test.go @@ -379,6 +379,40 @@ func TestBuildBodyOmitsEmptyFields(t *testing.T) { } } +// TestBuildBodyEmitsAnExplicitlySetZeroTemperature: reviewer-strict pins +// temperature 0, and 0 is the float zero value — so "omit when zero" silently +// dropped the one temperature that was set on purpose and the server sampled +// at its default. A role that SET a temperature always gets the key. +func TestBuildBodyEmitsAnExplicitlySetZeroTemperature(t *testing.T) { + p := &structuredProvider{ + meta: backendMeta{Provider: "openai", Endpoint: "http://x/v1"}, + directives: Directives{Role: "reviewer-strict", Temperature: 0, TemperatureSet: true}, + } + spec, _ := schema.For(schema.RoleReview) + body := p.buildBody(llm.CompletionRequest{ + Messages: []llm.Message{{Role: "user", Content: "hi"}}, + }, spec, MechJSONObject, "m") + got, ok := body["temperature"] + if !ok { + t.Fatal("an explicitly set temperature of 0 was omitted from the body") + } + if got != 0.0 { + t.Fatalf("temperature = %v, want 0", got) + } + b, err := json.Marshal(body) + if err != nil || !strings.Contains(string(b), `"temperature":0`) { + t.Errorf("body = %s err=%v", b, err) + } + // A request that carries its own non-zero temperature still wins. + body = p.buildBody(llm.CompletionRequest{ + Messages: []llm.Message{{Role: "user", Content: "hi"}}, + Temperature: 0.3, + }, spec, MechJSONObject, "m") + if body["temperature"] != 0.3 { + t.Errorf("request temperature overridden: %v", body["temperature"]) + } +} + func TestStructuredPathObservesThroughput(t *testing.T) { ResetCapabilityCache() GlobalThroughput.Reset() diff --git a/pkg/cli/gateprompt.go b/pkg/cli/gateprompt.go index eb41519..9cf7a8f 100644 --- a/pkg/cli/gateprompt.go +++ b/pkg/cli/gateprompt.go @@ -43,6 +43,10 @@ type TerminalGateHost struct { // and in raw mode a fast typist could lose a keystroke the same way. One // decoder for the whole terminal keeps every unconsumed byte in one place. keys *KeyReader + // raw is the terminal mode handle held while read() is waiting for a key, + // so AskGate can restore the terminal when ctx ends before an answer. + rawMu sync.Mutex + raw *RawMode } // NewTerminalGateHost returns a host bound to the process terminal. @@ -144,11 +148,49 @@ func (h *TerminalGateHost) AskGate(ctx context.Context, g Gate) (GateAnswer, boo } return r.ans, r.ok case <-ctx.Done(): + // The read goroutine still owns the terminal: it put stdin into raw + // mode and is blocked in ReadKey. Returning without restoring left + // the shell in raw mode (no echo, no line editing) whenever a gate + // was abandoned by a stop or a shutdown rather than answered. Restore + // here; the goroutine's own Restore is idempotent. + h.restoreRaw() write("\n") return GateAnswer{}, false } } +// setRaw / restoreRaw track the RawMode handle held by the read goroutine so +// the ctx.Done branch of AskGate can return the terminal to cooked mode. +func (h *TerminalGateHost) setRaw(rm *RawMode) { + h.rawMu.Lock() + h.raw = rm + h.rawMu.Unlock() +} + +func (h *TerminalGateHost) restoreRaw() { + h.rawMu.Lock() + rm := h.raw + h.raw = nil + h.rawMu.Unlock() + rm.Restore() // nil-safe +} + +// releaseRaw is the read goroutine's own restore. A reader that was abandoned +// by ctx.Done and wakes up only after a NEWER gate has put the terminal into +// raw mode must not put it back into cooked mode under that gate, so it only +// restores when its handle is still the current one (or already released). +func (h *TerminalGateHost) releaseRaw(rm *RawMode) { + h.rawMu.Lock() + superseded := h.raw != nil && h.raw != rm + if h.raw == rm { + h.raw = nil + } + h.rawMu.Unlock() + if !superseded { + rm.Restore() + } +} + // reader returns the host's single key decoder, creating it on first use. // // Callers hold h.mu (AskGate is the only path in), so no extra locking. @@ -168,29 +210,33 @@ func (h *TerminalGateHost) read(g Gate) (GateAnswer, bool) { if err != nil || rm == nil { return h.readLine(g, "") } + // Published so AskGate's ctx.Done branch can restore the terminal while + // this goroutine is still blocked in ReadKey; restoreRaw is idempotent + // with the Restore calls below. + h.setRaw(rm) kr := h.reader() for { k, kerr := kr.ReadKey() if kerr != nil { - rm.Restore() + h.releaseRaw(rm) return GateAnswer{}, false } switch k.Type { case KeyCtrlC, KeyCtrlD, KeyEscape: - rm.Restore() + h.releaseRaw(rm) return GateAnswer{Value: GateInterrupted}, true case KeyEnter: - rm.Restore() + h.releaseRaw(rm) return h.readLine(g, "") } if ans, ok := g.ResolveKey(k); ok { - rm.Restore() + h.releaseRaw(rm) return ans, true } // A key that belongs to a freeform option, or any other printable // character, starts a typed answer with that character already in. if k.Type == KeyRune { - rm.Restore() + h.releaseRaw(rm) _, _ = io.WriteString(h.out(), string(k.Rune)) return h.readLine(g, string(k.Rune)) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 4256a34..eb62267 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -539,12 +539,12 @@ type Config struct { ContextCompact bool `yaml:"context_compact" json:"context_compact"` // ContextCompactEngine: heuristic | llm | auto (LLM with heuristic fallback). ContextCompactEngine string `yaml:"context_compact_engine" json:"context_compact_engine"` - // ReactCompact enables ReAct conversation compaction at CHECKPOINT and - // RESUME — it does NOT compact a live agent call between its iterations. - // loop.LiveReactCompactionWired is the constant of record (it is false, and - // nothing an operator sets changes it); loop.ReactCompactionStatus renders - // the honest one-line claim. This setting says what was asked for, not what - // the harness does. + // ReactCompact enables ReAct conversation compaction: deterministic elision + // of old tool results on LIVE requests (pkg/backends.LiveElide, installed + // for tool-using roles) and elision-then-digest at CHECKPOINT and RESUME. + // loop.LiveReactCompactionWired is the constant of record for the live + // half; loop.ReactCompactionStatus renders the honest one-line claim. This + // setting says what was asked for; the constant says what exists. ReactCompact bool `yaml:"react_compact" json:"react_compact"` // ReactCompactAtPercent triggers ReAct compaction at this % of MaxContextKB // (little-coder default 80). <=0 or >=100 disables. diff --git a/pkg/config/schema.go b/pkg/config/schema.go index 4f45df7..c023c08 100644 --- a/pkg/config/schema.go +++ b/pkg/config/schema.go @@ -97,7 +97,7 @@ var schemaFields = map[string]schemaMeta{ "max_context_kb": {"Max context KB", "Legacy prompt budget in KB, used when a model profile declares no real context window", "context", nil, false, false, false, false}, "context_compact": {"CONTEXT compaction", "Summarize CONTEXT.md mid-run when it outgrows the budget", "context", nil, false, false, false, false}, "context_compact_engine": {"Compaction engine", "How CONTEXT.md is compacted", "context", []string{"heuristic", "llm", "auto"}, false, false, false, false}, - "react_compact": {"ReAct compaction", "Compact the agent conversation at checkpoint and resume when it approaches the window. A single long agent call is NOT compacted mid-flight", + "react_compact": {"ReAct compaction", "Compact the agent conversation when it approaches the window: old tool results are elided deterministically on live requests, and a checkpoint or resume is elided then digested", "context", nil, false, false, false, false}, "react_compact_at_percent": {"ReAct compaction threshold", "Percentage of the context budget at which the conversation is compacted (0 disables)", "context", nil, false, false, false, false}, diff --git a/pkg/hitl/pending.go b/pkg/hitl/pending.go index 25a72e8..930472b 100644 --- a/pkg/hitl/pending.go +++ b/pkg/hitl/pending.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "strings" "time" @@ -51,13 +52,196 @@ func Clear(slmDir, kind string) { _ = os.Remove(AnswersPath(slmDir, kind)) } -// ClearAll removes every known pending HITL ask/answer pair. +// ClearAll removes every known pending HITL ask/answer pair, including the +// per-ask files (see WriteAskID). func ClearAll(slmDir string) { if slmDir == "" { return } for _, kind := range []string{"clarify", "plan", "continue", "escalate", "shell"} { Clear(slmDir, kind) + _ = os.RemoveAll(AsksDir(slmDir, kind)) + _ = os.RemoveAll(AnswersDir(slmDir, kind)) + } +} + +// ── Per-ask files ────────────────────────────────────────────────────────── +// +// The single ask.json / answers.json slot above is fine for asks that are by +// construction one-at-a-time (plan approval, continue). Shell approval is not: +// a wave runs N workers in parallel and each may need a command approved. With +// one slot the second WriteAsk overwrote the first (and deleted its answer), +// and WaitAnswersForID's "not my id → delete it" step made the loser destroy +// the winner's decision. Each ask now lives in its own pair of files: +// +// .slmcode//asks/.json +// .slmcode//answers/.json +// +// so N concurrent asks each wait on exactly their own answer. + +// AsksDir is where per-ask payloads live for kind. +func AsksDir(slmDir, kind string) string { return filepath.Join(slmDir, kind, "asks") } + +// AnswersDir is where per-ask answers live for kind. +func AnswersDir(slmDir, kind string) string { return filepath.Join(slmDir, kind, "answers") } + +// AskIDPath / AnswerIDPath name the files for one ask id. +func AskIDPath(slmDir, kind, id string) string { + return filepath.Join(AsksDir(slmDir, kind), safeAskID(id)+".json") +} + +// AnswerIDPath is the answer file for one ask id. +func AnswerIDPath(slmDir, kind, id string) string { + return filepath.Join(AnswersDir(slmDir, kind), safeAskID(id)+".json") +} + +// safeAskID flattens an id into one file-name component. +func safeAskID(id string) string { + id = strings.TrimSpace(id) + var b strings.Builder + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := strings.Trim(b.String(), ".") + if out == "" { + out = "ask" + } + if len(out) > 120 { + out = out[:120] + } + return out +} + +// WriteAskID persists one ask under its own id without touching any other +// pending ask of the same kind. +func WriteAskID(slmDir, kind, id string, payload any) error { + if slmDir == "" { + return nil + } + if err := os.MkdirAll(AsksDir(slmDir, kind), 0o750); err != nil { // HITL ask/answer state, owner-only + return err + } + _ = os.Remove(AnswerIDPath(slmDir, kind, id)) + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return atomicfile.Write(AskIDPath(slmDir, kind, id), data, 0o644) +} + +// ReadAskID loads one ask by id. +func ReadAskID(slmDir, kind, id string, dest any) (bool, error) { + data, err := os.ReadFile(AskIDPath(slmDir, kind, id)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + if err := json.Unmarshal(data, dest); err != nil { + return false, err + } + return true, nil +} + +// ListAskIDs returns the ids of every pending per-ask file for kind, oldest +// first (ids carry a nanosecond timestamp, so name order is creation order). +func ListAskIDs(slmDir, kind string) ([]string, error) { + entries, err := os.ReadDir(AsksDir(slmDir, kind)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var ids []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + ids = append(ids, strings.TrimSuffix(e.Name(), ".json")) + } + sort.Strings(ids) + return ids, nil +} + +// WriteAnswerIDOnce stores the answer for one ask, refusing to overwrite an +// existing decision (os.IsExist on the returned error). +func WriteAnswerIDOnce(slmDir, kind, id string, payload any) error { + if slmDir == "" { + return nil + } + if err := os.MkdirAll(AnswersDir(slmDir, kind), 0o750); err != nil { // HITL ask/answer state, owner-only + return err + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return atomicfile.WriteOnce(AnswerIDPath(slmDir, kind, id), data, 0o644) +} + +// ReadAnswerID loads the answer for one ask if present. +func ReadAnswerID(slmDir, kind, id string, dest any) (bool, error) { + data, err := os.ReadFile(AnswerIDPath(slmDir, kind, id)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + if err := json.Unmarshal(data, dest); err != nil { + return false, err + } + return true, nil +} + +// ClearID removes the ask and answer files for one id only. +func ClearID(slmDir, kind, id string) { + if slmDir == "" { + return + } + _ = os.Remove(AskIDPath(slmDir, kind, id)) + _ = os.Remove(AnswerIDPath(slmDir, kind, id)) +} + +// WaitAnswerID polls for THIS ask's answer until timeout or ctx cancel. It +// never touches another ask's files. On timeout the ask is withdrawn (its +// files removed) and ok=false is returned; an answer file that appears after +// the deadline is ignored the same way. +func WaitAnswerID(ctx context.Context, slmDir, kind, id string, timeout time.Duration, dest any) (bool, error) { + if timeout <= 0 { + timeout = 2 * time.Minute + } + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + ok, err := ReadAnswerID(slmDir, kind, id, dest) + if err != nil { + return false, err + } + if ok { + if info, serr := os.Stat(AnswerIDPath(slmDir, kind, id)); serr == nil && info.ModTime().After(deadline) { + ClearID(slmDir, kind, id) + return false, nil + } + return true, nil + } + if time.Now().After(deadline) { + ClearID(slmDir, kind, id) + return false, nil + } + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-ticker.C: + } } } diff --git a/pkg/hitl/perask_test.go b/pkg/hitl/perask_test.go new file mode 100644 index 0000000..a4452ba --- /dev/null +++ b/pkg/hitl/perask_test.go @@ -0,0 +1,97 @@ +package hitl + +import ( + "context" + "os" + "sync" + "testing" + "time" +) + +type perAskAnswer struct { + AskID string `json:"ask_id"` + Decision string `json:"decision"` +} + +// Two parallel workers each raise a shell ask; each must receive exactly its +// own decision, and neither may disturb the other's files. With the single +// ask.json/answers.json slot the second ask erased the first and the loser's +// WaitAnswersForID deleted the winner's answer. +func TestConcurrentAsksGetTheirOwnDecision(t *testing.T) { + dir := t.TempDir() + if err := WriteAskID(dir, "shell", "shell-1", map[string]string{"id": "shell-1", "command": "go test ./a"}); err != nil { + t.Fatal(err) + } + if err := WriteAskID(dir, "shell", "shell-2", map[string]string{"id": "shell-2", "command": "rm -rf build"}); err != nil { + t.Fatal(err) + } + ids, err := ListAskIDs(dir, "shell") + if err != nil || len(ids) != 2 || ids[0] != "shell-1" || ids[1] != "shell-2" { + t.Fatalf("ListAskIDs = %v err=%v", ids, err) + } + + var wg sync.WaitGroup + results := make([]perAskAnswer, 2) + oks := make([]bool, 2) + for i, id := range []string{"shell-1", "shell-2"} { + wg.Add(1) + go func(i int, id string) { + defer wg.Done() + ok, err := WaitAnswerID(context.Background(), dir, "shell", id, 5*time.Second, &results[i]) + if err != nil { + t.Errorf("wait %s: %v", id, err) + } + oks[i] = ok + }(i, id) + } + // Answer the SECOND ask first with a deny, then the first with an approve. + time.Sleep(60 * time.Millisecond) + if err := WriteAnswerIDOnce(dir, "shell", "shell-2", perAskAnswer{AskID: "shell-2", Decision: "deny"}); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + if err := WriteAnswerIDOnce(dir, "shell", "shell-1", perAskAnswer{AskID: "shell-1", Decision: "approve"}); err != nil { + t.Fatal(err) + } + wg.Wait() + + if !oks[0] || results[0].Decision != "approve" || results[0].AskID != "shell-1" { + t.Fatalf("ask 1 got %+v ok=%v", results[0], oks[0]) + } + if !oks[1] || results[1].Decision != "deny" || results[1].AskID != "shell-2" { + t.Fatalf("ask 2 got %+v ok=%v", results[1], oks[1]) + } + // A second decision on an answered ask is refused, not overwritten. + if err := WriteAnswerIDOnce(dir, "shell", "shell-1", perAskAnswer{AskID: "shell-1", Decision: "deny"}); err == nil || !os.IsExist(err) { + t.Fatalf("second answer should be refused with IsExist, got %v", err) + } + // Clearing one id leaves the other's files alone. + ClearID(dir, "shell", "shell-1") + if _, err := os.Stat(AskIDPath(dir, "shell", "shell-2")); err != nil { + t.Fatalf("ClearID(shell-1) removed shell-2's ask: %v", err) + } + if _, err := os.Stat(AnswerIDPath(dir, "shell", "shell-1")); err == nil { + t.Fatal("ClearID left shell-1's answer behind") + } +} + +func TestWaitAnswerIDTimesOutAndWithdraws(t *testing.T) { + dir := t.TempDir() + if err := WriteAskID(dir, "shell", "shell-9", map[string]string{"id": "shell-9"}); err != nil { + t.Fatal(err) + } + var got perAskAnswer + ok, err := WaitAnswerID(context.Background(), dir, "shell", "shell-9", 300*time.Millisecond, &got) + if err != nil || ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if _, err := os.Stat(AskIDPath(dir, "shell", "shell-9")); err == nil { + t.Fatal("timed-out ask was not withdrawn") + } + // ClearAll sweeps the per-ask directories too. + _ = WriteAskID(dir, "shell", "shell-10", map[string]string{"id": "shell-10"}) + ClearAll(dir) + if ids, _ := ListAskIDs(dir, "shell"); len(ids) != 0 { + t.Fatalf("ClearAll left per-ask files: %v", ids) + } +} diff --git a/pkg/loop/budget.go b/pkg/loop/budget.go index 232ad85..2da9448 100644 --- a/pkg/loop/budget.go +++ b/pkg/loop/budget.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "time" "github.com/UnicoLab/slmcode/pkg/agents" "github.com/UnicoLab/slmcode/pkg/stream" @@ -206,18 +207,78 @@ func (r *Runner) spend(taskID, what string) bool { return false } -// noteExtraRequests records LLM round-trips a task issued beyond the budget -// units it spent — the extra speculative slots of a review race. -func (r *Runner) noteExtraRequests(taskID string, n int) { +// budgetExhausted reports whether a task has no calls left. +func (r *Runner) budgetExhausted(taskID string) bool { + return r != nil && taskID != "" && r.budget().remaining(taskID) == 0 +} + +// roleBudget is the measured budget for one request of role, ignoring the +// run's remaining runway: min(Timeout, RoleTimeout(base role)). It is for a +// site with no ctx to hand; requestTimeout is the ctx-aware version every +// dispatch should use. +func (r *Runner) roleBudget(role string) time.Duration { if r == nil { + return 0 + } + budget := r.Timeout + if r.RoleTimeout == nil { + return budget + } + base, _ := agents.BaseRoleID(role) + if measured := r.RoleTimeout(nil, base); measured > 0 && (budget <= 0 || measured < budget) { + return measured + } + return budget +} + +// requestTimeout is the budget for one request of role right now: +// callTimeout (the flat Timeout clamped to the runway) capped by the measured +// per-role budget. The measured value can only TIGHTEN the flat one — the +// orchestrator's policy already floors it per role class and ceilings it at +// task_timeout, so nothing here can hand out more than Timeout. +func (r *Runner) requestTimeout(ctx context.Context, role string) time.Duration { + if r == nil { + return 0 + } + budget := r.callTimeout(ctx) + if r.RoleTimeout == nil { + return budget + } + base, _ := agents.BaseRoleID(role) + if measured := r.RoleTimeout(ctx, base); measured > 0 && (budget <= 0 || measured < budget) { + return measured + } + return budget +} + +// applyRoleTimeout tightens req.Timeout to the measured budget for its role. +// A request that arrived with no timeout gets the measured one; one that +// arrived with a larger budget is cut down; a smaller one is left alone. +func (r *Runner) applyRoleTimeout(ctx context.Context, req *ggagent.SubAgentRequest) { + if r == nil || req == nil { + return + } + want := r.requestTimeout(ctx, req.AgentID) + if want <= 0 { return } - r.budget().note(taskID, n) + if req.Timeout <= 0 || want < req.Timeout { + req.Timeout = want + } } -// budgetExhausted reports whether a task has no calls left. -func (r *Runner) budgetExhausted(taskID string) bool { - return r != nil && taskID != "" && r.budget().remaining(taskID) == 0 +// noteRoleLatency reports one request's duration to OnRoleLatency under the +// BASE role (an escalation rung is the same role on a bigger model, and the +// budget is looked up under the base too). Evidence follows the orchestrator's +// rule: a success measures the role, a timeout is a censored lower bound worth +// keeping, any other failure says nothing about how long the role needs. +func (r *Runner) noteRoleLatency(role string, d time.Duration, err error, res ggagent.SubAgentResult) { + if r == nil || r.OnRoleLatency == nil || d <= 0 { + return + } + base, _ := agents.BaseRoleID(role) + failed := err != nil || res.Error != nil + r.OnRoleLatency(base, d, !failed || isTimeoutResult(err, res)) } // execOne runs a single subagent request under the task's ctx tag, after @@ -245,12 +306,20 @@ func (r *Runner) execOne(ctx context.Context, taskID, what string, req ggagent.S return ggagent.SubAgentResult{Error: fmt.Errorf("nil executor")}, true } defer r.streamTokens(req.AgentID, taskID)() + // The measured per-role budget, not the flat task ceiling: reviewers and + // correctors own most of a run's wall clock and used to be the only roles + // that never contributed a latency sample nor received a measured budget. + r.applyRoleTimeout(ctx, &req) + start := time.Now() res, err := r.Executor.ExecuteSubAgents(r.agentCtx(ctx, taskID, req.AgentID), []ggagent.SubAgentRequest{req}, r.Shared) + elapsed := time.Since(start) if len(res) == 0 { + r.noteRoleLatency(req.AgentID, elapsed, err, ggagent.SubAgentResult{Error: err}) return ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: taskID, Error: err}, true } out := res[0] + r.noteRoleLatency(req.AgentID, elapsed, err, out) if out.Error == nil && err != nil && outputString(out) == "" { out.Error = err } diff --git a/pkg/loop/budget_roletimeout_test.go b/pkg/loop/budget_roletimeout_test.go new file mode 100644 index 0000000..458f6b6 --- /dev/null +++ b/pkg/loop/budget_roletimeout_test.go @@ -0,0 +1,145 @@ +package loop + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/agents" + "github.com/UnicoLab/slmcode/pkg/plan" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" +) + +// budgetTimeoutExec records the timeout each request carries and answers from a +// script, so the clamp execOne applies is observable at the executor. +type budgetTimeoutExec struct { + mu sync.Mutex + reqs []ggagent.SubAgentRequest + err error +} + +func (e *budgetTimeoutExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + e.reqs = append(e.reqs, reqs...) + e.mu.Unlock() + time.Sleep(time.Millisecond) + if e.err != nil { + return []ggagent.SubAgentResult{{AgentID: reqs[0].AgentID, Error: e.err}}, e.err + } + return []ggagent.SubAgentResult{{AgentID: reqs[0].AgentID, TaskID: reqs[0].TaskID, Output: "ok"}}, nil +} + +func (e *budgetTimeoutExec) last() ggagent.SubAgentRequest { + e.mu.Lock() + defer e.mu.Unlock() + return e.reqs[len(e.reqs)-1] +} + +type latencyNote struct { + role string + d time.Duration + evidence bool +} + +// execOne dispatches on min(callTimeout, RoleTimeout(base role)) and reports +// every request's duration under the base role with the orchestrator's +// evidence rule. +func TestExecOneUsesTheMeasuredRoleBudget(t *testing.T) { + exec := &budgetTimeoutExec{} + var mu sync.Mutex + var notes []latencyNote + r := &Runner{ + Timeout: 8 * time.Minute, + Executor: exec, + RoleTimeout: func(_ context.Context, role string) time.Duration { + if role == plan.RoleReviewer { + return time.Minute + } + return 0 // no measurement: keep the flat budget + }, + OnRoleLatency: func(role string, d time.Duration, evidence bool) { + mu.Lock() + notes = append(notes, latencyNote{role, d, evidence}) + mu.Unlock() + }, + } + ctx := context.Background() + + // A measured role is tightened to its budget. + if _, ok := r.execOne(ctx, "T1", "review", ggagent.SubAgentRequest{ + AgentID: plan.RoleReviewer, Timeout: r.callTimeout(ctx)}); !ok { + t.Fatal("execOne refused the call") + } + if got := exec.last().Timeout; got != time.Minute { + t.Fatalf("reviewer dispatched with %v, want the measured 1m", got) + } + + // An unmeasured role keeps the flat budget; an escalation rung is looked + // up and recorded under its BASE role. + escalated := agents.EscalatedRoleID(plan.RoleWorker, 2) + if _, ok := r.execOne(ctx, "T1", "worker", ggagent.SubAgentRequest{ + AgentID: escalated, Timeout: r.callTimeout(ctx)}); !ok { + t.Fatal("execOne refused the call") + } + if got := exec.last().Timeout; got != 8*time.Minute { + t.Fatalf("unmeasured worker dispatched with %v, want the flat 8m", got) + } + + // A request that arrived tighter than the measurement stays tight. + if _, ok := r.execOne(ctx, "T1", "review", ggagent.SubAgentRequest{ + AgentID: plan.RoleReviewer, Timeout: 10 * time.Second}); !ok { + t.Fatal("execOne refused the call") + } + if got := exec.last().Timeout; got != 10*time.Second { + t.Fatalf("a tighter caller budget was widened to %v", got) + } + + // The ctx-less variant used by reviewSlots agrees. + if got := r.roleBudget(plan.RoleReviewer); got != time.Minute { + t.Fatalf("roleBudget(reviewer) = %v, want 1m", got) + } + if got := r.roleBudget(escalated); got != 8*time.Minute { + t.Fatalf("roleBudget(escalated worker) = %v, want 8m", got) + } + + mu.Lock() + defer mu.Unlock() + if len(notes) != 3 { + t.Fatalf("recorded %d latency samples for 3 requests: %+v", len(notes), notes) + } + if notes[1].role != plan.RoleWorker { + t.Fatalf("escalated request recorded under %q, want the base role %q", notes[1].role, plan.RoleWorker) + } + for _, n := range notes { + if !n.evidence || n.d <= 0 { + t.Fatalf("a successful call is evidence with a positive duration: %+v", n) + } + } +} + +// A provider error that returns in two seconds says nothing about how long the +// role needs: recorded as non-evidence, exactly as the phase roles do. +func TestExecOneDoesNotCountAProviderErrorAsLatencyEvidence(t *testing.T) { + exec := &budgetTimeoutExec{err: errors.New("chat failed: 502")} + var got []latencyNote + r := &Runner{ + Timeout: time.Minute, + Executor: exec, + OnRoleLatency: func(role string, d time.Duration, evidence bool) { + got = append(got, latencyNote{role, d, evidence}) + }, + } + r.execOne(context.Background(), "T1", "worker", ggagent.SubAgentRequest{AgentID: plan.RoleWorker}) + if len(got) != 1 || got[0].evidence { + t.Fatalf("a provider error was recorded as evidence: %+v", got) + } + // Without the hooks nothing changes: the request keeps what it came with. + bare := &Runner{Timeout: time.Minute, Executor: &budgetTimeoutExec{}} + bare.execOne(context.Background(), "T1", "worker", ggagent.SubAgentRequest{AgentID: plan.RoleWorker, Timeout: 30 * time.Second}) + if got := bare.Executor.(*budgetTimeoutExec).last().Timeout; got != 30*time.Second { + t.Fatalf("with no RoleTimeout the request timeout changed to %v", got) + } +} diff --git a/pkg/loop/critique.go b/pkg/loop/critique.go index d6a38da..fed85d0 100644 --- a/pkg/loop/critique.go +++ b/pkg/loop/critique.go @@ -9,6 +9,7 @@ import ( "github.com/UnicoLab/slmcode/pkg/memory" + "github.com/UnicoLab/slmcode/pkg/agents" "github.com/UnicoLab/slmcode/pkg/context/textutil" "github.com/UnicoLab/slmcode/pkg/evolve" "github.com/UnicoLab/slmcode/pkg/multipass" @@ -296,7 +297,7 @@ func (r *Runner) critiquePass(ctx context.Context, t *plan.Task, role string, }) res, ok := r.execOne(ctx, t.ID, "self-critique", ggagent.SubAgentRequest{ AgentID: r.correctorID(), Input: corrIn, - Timeout: r.Timeout, ShareState: true, TaskID: t.ID, + Timeout: r.callTimeout(ctx), ShareState: true, TaskID: t.ID, }) if !ok { // Budget exhausted: escalate rather than loop. @@ -425,14 +426,38 @@ func (r *Runner) focusDiff(t plan.Task, maxBytes int) string { return textutil.TruncateDefault(diff, maxBytes) } +// correctorOutputBudget bounds the previous output shown to the corrector. The +// harness evidence inside it is budgeted separately by clipForReview. +const correctorOutputBudget = 2500 + // formatCorrectPrompt builds the corrector prompt. Unlike the old package-level // helper it varies per attempt: it carries the real diff of the previous // attempt and names the fixes that were already tried and did not work. +// +// The previous output is clipped the way the reviewer's is (clipForReview): +// the model's prose is budgeted and the harness-appended evidence — the +// `## Deterministic smoke` / `## Acceptance criteria` sections and the failing +// command's observation that runGates puts at the END of Task.Output — is kept +// whole. A head-first clip used to delete exactly that once the prose passed +// 2.5 KB, so the corrector was told "fix the smoke failure" and never shown +// the failing command or its output. +// +// It also carries the same HARD SCOPE list, acceptance and language hint the +// worker was given: the scope gate grades the corrector against them too. func (r *Runner) formatCorrectPrompt(t plan.Task, review plan.ReviewResult) string { var b strings.Builder b.WriteString("Fix task " + t.ID + " after failed review.\n\n") b.WriteString("## Original task\n" + StripScopedPack(t.Description) + "\n\n") - b.WriteString("## Previous output\n" + truncate(t.Output, 2500) + "\n\n") + if h := detectProjectLangHint(r.rootDir()); h != "" { + b.WriteString("## Project language\n" + h + "\n\n") + } + if sec := agents.FocusFilesSection(t.Files); sec != "" { + b.WriteString(strings.TrimLeft(sec, "\n") + "\n") + } + if strings.TrimSpace(t.Acceptance) != "" { + b.WriteString("## Acceptance\n" + t.Acceptance + "\n\n") + } + b.WriteString("## Previous output\n" + clipForReview(t.Output, correctorOutputBudget) + "\n\n") if diff := r.focusDiff(t, 1500); diff != "" { b.WriteString("## What the previous attempt actually changed (git diff)\n" + "```diff\n" + diff + "\n```\n\n") diff --git a/pkg/loop/react_resume.go b/pkg/loop/react_resume.go index 0a8eb6e..0e8e923 100644 --- a/pkg/loop/react_resume.go +++ b/pkg/loop/react_resume.go @@ -131,9 +131,19 @@ func (r *Runner) applyResumeRequest(req *ggagent.SubAgentRequest, taskID string) // the restored transcript routinely ENDS on an assistant tool_calls // message. A user message may not sit between that and the tool results // the executor is about to append — every OpenAI-compatible server - // answers HTTP 400. The steer still reaches the model through req.Input. + // answers HTTP 400. + // + // The steer used to be left to req.Input on that path, and it never + // arrived: the agent library adds Input as a user turn on a COLD start + // only and, on a seeded conversation, keeps it out of the transcript. + // So the one resume that most needs the turn-budget warning — a worker + // interrupted deep in its tool loop — was the one that never saw it. + // A system message is legal at any position, so it goes in right after + // the leading system message (the technique + // compact.CompactChatMessagesWithDigest uses for its digest). if trailingUnansweredToolCalls(msgs) { - r.logf("%s finalize-steer kept out of the transcript: tool calls still pending", taskID) + req.Messages = injectSystemSteer(req.Messages, steer) + r.logf("%s finalize-steer injected as a system message: tool calls still pending", taskID) } else { req.Messages = append(req.Messages, llm.Message{Role: "user", Content: steer}) } @@ -147,6 +157,21 @@ func (r *Runner) applyResumeRequest(req *ggagent.SubAgentRequest, taskID string) return true } +// injectSystemSteer places steer as a system message immediately after the +// transcript's leading system message, or first when there is none. It never +// appends: the tail of a resumed transcript is an assistant tool_calls message +// whose results the executor is about to add, and nothing may sit between them. +func injectSystemSteer(msgs []llm.Message, steer string) []llm.Message { + at := 0 + if len(msgs) > 0 && strings.EqualFold(msgs[0].Role, compact.RoleSystem) { + at = 1 + } + out := make([]llm.Message, 0, len(msgs)+1) + out = append(out, msgs[:at]...) + out = append(out, llm.Message{Role: compact.RoleSystem, Content: steer}) + return append(out, msgs[at:]...) +} + // reactCompactMinMessages is the floor below which compaction never triggers. const reactCompactMinMessages = 10 @@ -219,42 +244,33 @@ func (r *Runner) maybeCompactReact(msgs []session.ReactMessage, agentID string, // LiveReactCompactionWired reports whether per-iteration ReAct compaction is // actually installed in the request path. // -// It is FALSE, and it is a constant rather than a config field because nothing -// an operator can set changes it: the call site does not exist. Every consumer -// that describes compaction to a human — pkg/readiness, the config docstring, -// the run notice — must read this rather than react_compact, because -// react_compact says what the operator ASKED FOR and this says what the harness -// DOES. Telling someone their 16-iteration worker is protected from context -// exhaustion when only the resume path compacts is worse than telling them -// nothing. -// -// What IS wired, with react_compact on: -// - document (CONTEXT.md) compaction — pkg/compact, driven by the orchestrator; -// - ReAct compaction at CHECKPOINT and RESUME — maybeCompactReact, called from -// saveReactFromResult and applyResumeRequest. -// -// What is not: compaction between iterations of a live agent call. +// It is TRUE. ggagent's SubAgentRequest carries no per-iteration callback and +// its Middleware interface is BeforeRun/AfterRun only, so nothing in THIS +// package sees iteration N of a 16-iteration worker — but a ReAct iteration is +// exactly one llm.Provider completion, and every role is bound to its own +// provider registration. pkg/backends.BindRole therefore stacks a +// liveElideProvider (pkg/backends/live_elide.go) outermost over every +// tool-using role whose factory carries a LiveElide policy; the orchestrator +// sets that policy from react_compact / react_compact_at_percent, and the +// window comes from the model profile's context_limit. The same wall was hit +// by live token streaming and the way round it was the same wrapper route. // -// The blocker and the way through, so the next person does not re-derive it: -// ggagent's SubAgentRequest carries no per-iteration callback and its -// Middleware interface is BeforeRun/AfterRun only, so nothing in this package -// sees iteration N of a 16-iteration worker. The same wall was hit by live -// token streaming, and the way round it was a PROVIDER WRAPPER — a ReAct -// iteration is exactly one llm.Provider.Complete call, so a wrapper registered -// under the role-bound key (pkg/backends.BindRole, which already stacks -// structuredProvider over streamTeeProvider over retryProvider) sees every -// iteration's full transcript and can rewrite req.Messages. +// It stays a constant rather than a config field because nothing an operator +// sets changes whether the call site EXISTS — react_compact says whether it +// is used. Every consumer that describes compaction to a human — pkg/readiness, +// the config docstring, the run notice — must read this rather than +// react_compact. // -// That wrapper is NOT this method, and it is why this is still false: the -// policy below is written for a CHECKPOINT transcript, and -// compact.CompactChatMessagesWithDigest folds the head of the conversation into -// a system digest and appends a "resuming" user turn. On a live request the -// head is the role's system prompt — the tool contract — and dropping it -// mid-call would break the agent far more reliably than a long context does. -// Wiring this needs a live variant that pins the leading system message and -// omits the resume notice; that belongs beside the other provider wrappers in -// pkg/backends. -const LiveReactCompactionWired = false +// What the live path does, and deliberately does not do: it elides old tool +// RESULTS deterministically (the last compact.DefaultElideKeepLast stay +// verbatim), keeping every tool call, every tool_call_id pair, the leading +// system message and every user/assistant turn. It never summarizes. +// maybeCompactReact below may fold the head of a CHECKPOINT transcript into a +// system digest, but on a live request the head is the role's tool contract, +// and dropping it mid-call would break the agent more reliably than a long +// context does. Summarization therefore stays where it was: at checkpoint and +// resume, after the same elision has been tried first. +const LiveReactCompactionWired = true // ReactCompactionStatus describes, in one line, what compaction the harness is // actually performing for a given react_compact setting. Consumers that show @@ -264,17 +280,17 @@ func ReactCompactionStatus(reactCompact bool) string { return "ReAct compaction is off" } if LiveReactCompactionWired { - return "ReAct compaction is on for live iterations, checkpoints and resume" + return "ReAct compaction is on for live iterations (old tool results elided deterministically), checkpoints and resume" } return "ReAct compaction runs at checkpoint/resume only — a single long agent call is not compacted mid-flight" } -// CompactLiveMessages is the LIVE per-iteration compaction entry point. +// CompactLiveMessages applies the CHECKPOINT policy — deterministic elision, +// then the structured digest — to an in-memory transcript. // -// It is the POLICY half of a protection whose call site does not exist yet — -// see LiveReactCompactionWired for the blocker, the proposed provider-wrapper -// design, and the reason this policy cannot simply be pointed at a live -// transcript as written. +// It is exposed for embedders that hold a transcript between calls of their +// own. The harness's live path is the provider wrapper described at +// LiveReactCompactionWired, which elides only and does not go through here. func (r *Runner) CompactLiveMessages(agentID string, iteration int, msgs []llm.Message) []llm.Message { if r == nil || !r.ReactCompact || len(msgs) < reactCompactMinMessages { return msgs diff --git a/pkg/loop/react_resume_test.go b/pkg/loop/react_resume_test.go index 9ad4cc0..36978e1 100644 --- a/pkg/loop/react_resume_test.go +++ b/pkg/loop/react_resume_test.go @@ -225,6 +225,112 @@ func TestApplyResumeInjectsFinalizeSteer(t *testing.T) { } } +// steerCaptureExec records the messages the worker was resumed with. +type steerCaptureExec struct { + mu sync.Mutex + root string + seen [][]llm.Message +} + +func (e *steerCaptureExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]ggagent.SubAgentResult, len(reqs)) + for i, req := range reqs { + if req.AgentID == plan.RoleReviewer { + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, Output: `{"approved":true,"score":90,"summary":"ok"}`} + continue + } + e.seen = append(e.seen, append([]llm.Message(nil), req.Messages...)) + _ = os.WriteFile(filepath.Join(e.root, "a.go"), []byte("package main\n\nconst Greeting = \"hi\"\n"), 0o644) + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"status":"done","summary":"finished","files_changed":["a.go"]}`} + } + return out, nil +} + +// TestResumeSteerReachesTheModelWhenToolCallsArePending: a checkpoint saved +// mid tool-loop ends on an unanswered assistant tool_calls message. The steer +// could not be appended as a user turn there, and req.Input is not added to a +// seeded conversation — so the turn-budget warning never reached the model on +// exactly the resume that needed it. It must arrive as a system message, in +// the transcript the executor is handed, without a user turn after the +// pending tool calls. +func TestResumeSteerReachesTheModelWhenToolCallsArePending(t *testing.T) { + root := t.TempDir() + slm := filepath.Join(root, ".slmcode") + _ = os.MkdirAll(slm, 0o755) + _ = os.WriteFile(filepath.Join(root, "a.go"), []byte("package main\n"), 0o644) + turn, err := session.BeginTurn(slm, "run-steer-pending", "update the greeting") + if err != nil { + t.Fatal(err) + } + pending := []session.ReactToolCall{{ID: "call_9", Type: "function", Name: "ws_edit", Arguments: `{"path":"a.go"}`}} + cp := session.ReactCheckpoint{ + TurnID: turn.ID, TaskID: "T1", AgentID: plan.RoleWorker, + Iteration: 13, MaxIterations: 16, + Messages: []session.ReactMessage{ + {Role: "system", Content: "You are the worker."}, + {Role: "user", Content: "update the greeting in a.go"}, + {Role: "assistant", Content: "editing", ToolCalls: pending}, + }, + PendingToolCalls: pending, + } + if err := session.SaveReactCheckpoint(slm, cp); err != nil { + t.Fatal(err) + } + + // The request builder alone: the steer is a system message right after the + // leading one, and nothing follows the pending tool calls. + r := NewRunner(nil, ggagent.NewSharedState()) + r.SlmDir, r.TurnID, r.FinalizeWarn = slm, turn.ID, true + req := &ggagent.SubAgentRequest{AgentID: plan.RoleWorker, Input: "continue"} + if !r.applyResumeRequest(req, "T1") { + t.Fatal("expected resume") + } + assertSteerInjected(t, req.Messages) + + // End to end: the executor is handed that transcript. + exec := &steerCaptureExec{root: root} + r2 := defaultRunner(t, root, exec) + r2.SlmDir, r2.TurnID, r2.FinalizeWarn = slm, turn.ID, true + r2.MaxRetries = 0 + board := &plan.Board{QueryID: turn.ID, Query: turn.Query, Tasks: []plan.Task{{ + ID: "T1", Title: "update the greeting", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "update the greeting in a.go", Acceptance: "greeting updated", Files: []string{"a.go"}, + }}} + if err := r2.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + if len(exec.seen) != 1 { + t.Fatalf("worker dispatched %d time(s), want 1", len(exec.seen)) + } + assertSteerInjected(t, exec.seen[0]) +} + +func assertSteerInjected(t *testing.T, msgs []llm.Message) { + t.Helper() + if len(msgs) < 4 { + t.Fatalf("transcript too short: %+v", msgs) + } + if msgs[0].Role != "system" || msgs[0].Content != "You are the worker." { + t.Fatalf("leading system message was displaced: %+v", msgs[0]) + } + if msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "TURN BUDGET") { + t.Fatalf("steer is not the system message after the leading one: %+v", msgs[1]) + } + last := msgs[len(msgs)-1] + if last.Role != "assistant" || len(last.ToolCalls) != 1 || last.ToolCalls[0].ID != "call_9" { + t.Fatalf("a message was appended after the pending tool calls: %+v", last) + } + for _, m := range msgs { + if m.Role == "user" && strings.Contains(m.Content, "TURN BUDGET") { + t.Fatal("steer was appended as a user turn behind unanswered tool calls (HTTP 400 on every server)") + } + } +} + func TestSaveLoadReactCheckpoint(t *testing.T) { slm := t.TempDir() cp := session.ReactCheckpoint{ diff --git a/pkg/loop/review_path_test.go b/pkg/loop/review_path_test.go new file mode 100644 index 0000000..74a9857 --- /dev/null +++ b/pkg/loop/review_path_test.go @@ -0,0 +1,488 @@ +package loop + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/backends" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/session" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" + "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" +) + +// ── the reviewer is not asked when its answer could not matter ───────────── + +// TestFastRejectSkipsTheReviewerOnAHardGateFailure: a FAILED deterministic +// smoke is a rejection whatever the reviewer says — applyHardGates overturns +// an approval in that state — so the reviewer request is pure cost. The verdict +// must be the gate's own words. +func TestFastRejectSkipsTheReviewerOnAHardGateFailure(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := &scriptedExec{answer: func(ggagent.SubAgentRequest, int) string { + return `{"approved":true,"score":95,"summary":"looks fine"}` + }} + r := defaultRunner(t, root, exec) + task := plan.Task{ + ID: "T1", Title: "Update a", Role: plan.RoleWorker, Column: plan.ColInReview, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + Output: `{"status":"done","summary":"updated","files_changed":["a.go"]}` + + failingSmokeSection("go test ./...", "--- FAIL: TestGreeting"), + } + g := r.gatherGateSignals(context.Background(), &task, r.snapshotTargets(task)) + if !g.smokeFail || !g.blocking() { + t.Fatalf("fixture does not trip the smoke gate: %+v", g) + } + review, raw, err := r.decideReview(context.Background(), task, g) + if err != nil { + t.Fatalf("decideReview: %v", err) + } + if n := exec.total(); n != 0 { + t.Fatalf("%d LLM request(s) were made for a verdict the gate had already decided", n) + } + if review.Approved || raw != "" { + t.Fatalf("hard gate failure was not a rejection: approved=%v raw=%q", review.Approved, raw) + } + summary, issue := g.rejectReason() + if review.Summary != summary { + t.Fatalf("summary = %q, want the gate's own %q", review.Summary, summary) + } + if len(review.Issues) != 1 || review.Issues[0] != issue { + t.Fatalf("issues = %v, want [%q]", review.Issues, issue) + } +} + +// TestFastRejectUsesTheWorkersOwnBlockedSummary: a worker that finalized with +// status blocked and left nothing on disk has already said what a rejection +// would say. Its summary is the verdict; no reviewer is consulted. +func TestFastRejectUsesTheWorkersOwnBlockedSummary(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := &scriptedExec{answer: func(ggagent.SubAgentRequest, int) string { + return `{"approved":true,"score":95,"summary":"looks fine"}` + }} + r := defaultRunner(t, root, exec) + task := plan.Task{ + ID: "T1", Title: "Update a", Role: plan.RoleWorker, Column: plan.ColInReview, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + Output: `{"status":"blocked","summary":"the greeting constant is defined in a file outside my scope","files_changed":[]}`, + } + g := r.gatherGateSignals(context.Background(), &task, r.snapshotTargets(task)) + review, _, err := r.decideReview(context.Background(), task, g) + if err != nil { + t.Fatalf("decideReview: %v", err) + } + if n := exec.total(); n != 0 { + t.Fatalf("%d LLM request(s) were made to reject a worker that rejected itself", n) + } + if review.Approved { + t.Fatal("a blocked worker with no evidence was approved") + } + if !strings.Contains(review.Summary, "outside my scope") || + len(review.Issues) != 1 || !strings.Contains(review.Issues[0], "outside my scope") { + t.Fatalf("verdict does not carry the worker's own words: summary=%q issues=%v", + review.Summary, review.Issues) + } + // A tester's failing command is its finding, not a defect: never fast-rejected. + tester := task + tester.Role = plan.RoleTester + if _, ok := g.fastReject(tester); ok { + t.Fatal("a tester was fast-rejected") + } +} + +// ── a reviewer transport error is not a verdict on the work ──────────────── + +// flakyReviewerExec fails the first N reviewer calls with a transport error and +// answers every later one; workers answer prose with no status JSON and no +// write, so nothing but the reviewer can approve the task. +type flakyReviewerExec struct { + mu sync.Mutex + reviewerErrors int + reviewerCalls int + workerCalls int +} + +func (e *flakyReviewerExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]ggagent.SubAgentResult, len(reqs)) + for i, req := range reqs { + if strings.Contains(req.AgentID, "review") { + e.reviewerCalls++ + if e.reviewerCalls <= e.reviewerErrors { + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Error: errors.New("chat failed: Post \"http://127.0.0.1:1/v1/chat/completions\": " + + "dial tcp 127.0.0.1:1: connect: connection refused")} + continue + } + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"approved":true,"score":90,"summary":"findings are sound"}`} + continue + } + e.workerCalls++ + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: "Findings: the parser already handles UTF-8 input correctly; no change is needed."} + } + return out, nil +} + +func (e *flakyReviewerExec) counts() (int, int) { + e.mu.Lock() + defer e.mu.Unlock() + return e.reviewerCalls, e.workerCalls +} + +// investigationTask is a task the evidence gate does not require a write for, +// so the reviewer's verdict is the only thing standing between it and done. +func investigationTask(id string, deps ...string) plan.Task { + return plan.Task{ + ID: id, Title: "Investigate the parser", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "Report whether the parser handles UTF-8 input.", Acceptance: "findings reported", + Files: []string{"parser.go"}, DependsOn: deps, + } +} + +func TestReviewerTransportErrorDoesNotBlockTheTask(t *testing.T) { + t.Run("one error: the reviewer is asked once more", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "parser.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := &flakyReviewerExec{reviewerErrors: 1} + r := defaultRunner(t, root, exec) + r.MaxRetries = 0 + board := &plan.Board{Tasks: []plan.Task{investigationTask("T1")}} + if err := r.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + got, _ := board.Get("T1") + if got.Column == plan.ColBlocked { + t.Fatalf("a reviewer transport error blocked the task: %+v", got) + } + if got.Column != plan.ColDone { + t.Fatalf("T1 column=%s error=%q, want done after the re-ask", got.Column, got.Error) + } + if reviews, _ := exec.counts(); reviews != 2 { + t.Fatalf("reviewer requests=%d, want exactly 2 (the failed one and one re-ask)", reviews) + } + }) + + t.Run("reviewer down for good: human backlog, dependents untouched", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "parser.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := &flakyReviewerExec{reviewerErrors: 1 << 20} + r := defaultRunner(t, root, exec) + r.MaxRetries = 0 + board := &plan.Board{Tasks: []plan.Task{investigationTask("T1"), investigationTask("T2", "T1")}} + _ = r.RunBoard(context.Background(), board) + t1, _ := board.Get("T1") + if t1.Column == plan.ColBlocked { + t.Fatalf("a reviewer outage blocked the task: %+v", t1) + } + if t1.Column != plan.ColToScope || !strings.Contains(t1.Error, "reviewer unavailable") { + t.Fatalf("T1 column=%s error=%q, want to_scope with a reviewer-unavailable reason", + t1.Column, t1.Error) + } + t2, _ := board.Get("T2") + if t2.Column != plan.ColReadyToDev { + t.Fatalf("dependent T2 column=%s error=%q, want ready_to_dev — nothing judged T1's work", + t2.Column, t2.Error) + } + if reviews, _ := exec.counts(); reviews != 2 { + t.Fatalf("reviewer requests=%d, want exactly 2 (one re-ask, then escalate)", reviews) + } + }) +} + +// ── a timed-out worker resumes; a transient error retries ─────────────────── + +// timeoutThenResumeExec times the first worker call out mid-ReAct (with a +// transcript, as the real executor returns one) and finishes the task on the +// second — but only from the restored transcript, never from a cold start. +type timeoutThenResumeExec struct { + mu sync.Mutex + root string + workerCalls int + sawResume bool + coldRestart bool +} + +func (e *timeoutThenResumeExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]ggagent.SubAgentResult, len(reqs)) + for i, req := range reqs { + if strings.Contains(req.AgentID, "review") { + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"approved":true,"score":90,"summary":"ok"}`} + continue + } + e.workerCalls++ + if e.workerCalls == 1 { + msgs := []llm.Message{ + {Role: "user", Content: req.Input}, + {Role: "assistant", Content: "editing a.go", ToolCalls: []llm.ToolCall{{ + ID: "call_1", Type: "function", + Function: llm.FunctionCall{Name: "ws_edit", Arguments: `{"path":"a.go"}`}, + }}}, + } + out[i] = ggagent.SubAgentResult{ + AgentID: req.AgentID, TaskID: req.TaskID, + Messages: msgs, Iteration: 3, PendingToolCalls: msgs[1].ToolCalls, + Error: context.DeadlineExceeded, + } + continue + } + if req.Resume && len(req.Messages) >= 2 { + e.sawResume = true + } else { + e.coldRestart = true + } + _ = os.WriteFile(filepath.Join(e.root, "a.go"), []byte("package main\n\nconst Greeting = \"hi\"\n"), 0o644) + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"status":"done","summary":"resumed and finished","files_changed":["a.go"]}`} + } + return out, nil +} + +func TestTimedOutWorkerIsResumedNotParked(t *testing.T) { + root := t.TempDir() + slm := filepath.Join(root, ".slmcode") + _ = os.MkdirAll(slm, 0o755) + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + turn, err := session.BeginTurn(slm, "run-timeout-1", "update the greeting") + if err != nil { + t.Fatal(err) + } + exec := &timeoutThenResumeExec{root: root} + r := defaultRunner(t, root, exec) + r.SlmDir = slm + r.TurnID = turn.ID + r.MaxRetries = 0 + r.Timeout = time.Second + var logs []string + r.Log = func(format string, args ...interface{}) { logs = append(logs, fmt.Sprintf(format, args...)) } + timeoutInterventions := 0 + r.OnEvent = func(kind, _, _, _, scope, _ string) { + if kind == "intervention" && scope == "timeout" { + timeoutInterventions++ + } + } + board := &plan.Board{QueryID: turn.ID, Query: turn.Query, Tasks: []plan.Task{{ + ID: "T1", Title: "update the greeting", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + }}} + if err := r.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v\n%s", err, strings.Join(logs, "\n")) + } + got, _ := board.Get("T1") + if got.Column != plan.ColDone { + t.Fatalf("T1 column=%s error=%q — a first-attempt timeout must be retried, not parked\n%s", + got.Column, got.Error, strings.Join(logs, "\n")) + } + if exec.workerCalls != 2 { + t.Fatalf("worker calls=%d, want 2 (the timeout and the resumed retry)", exec.workerCalls) + } + if !exec.sawResume || exec.coldRestart { + t.Fatalf("the retry did not resume from the checkpoint: sawResume=%v cold=%v", + exec.sawResume, exec.coldRestart) + } + if timeoutInterventions != 0 { + t.Fatalf("%d timeout intervention(s) raised for a retry the harness handled itself", timeoutInterventions) + } + if !strings.Contains(got.Notes, "RETRY-AFTER-TIMEOUT") { + t.Fatalf("the retry left no note on the task: %q", got.Notes) + } +} + +// TestTimedOutWorkerParksOnlyAtTheAttemptCeiling: with a ceiling of two, a +// worker that times out every time is dispatched exactly twice and then parked. +func TestTimedOutWorkerParksOnlyAtTheAttemptCeiling(t *testing.T) { + root := t.TempDir() + exec := &timeoutExec{} + r := defaultRunner(t, root, exec) + r.MaxRetries = 0 + r.MaxTaskAttempts = 2 + r.Timeout = time.Second + board := &plan.Board{Tasks: []plan.Task{{ + ID: "T1", Title: "one", Role: plan.RoleWorker, Column: plan.ColReadyToDev, Files: []string{"a.go"}, + }}} + if err := r.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + if got := exec.callCount(); got != 2 { + t.Fatalf("executor calls=%d, want 2 (ceiling)", got) + } + got, _ := board.Get("T1") + if got.Column != plan.ColToScope || !strings.Contains(got.Error, "timed out") { + t.Fatalf("T1 at the ceiling: column=%s error=%q, want to_scope with a timeout reason", got.Column, got.Error) + } +} + +// transientThenOKExec fails the first worker call with a connection-level +// error, then succeeds. +type transientThenOKExec struct { + mu sync.Mutex + root string + first error + workerCalls int +} + +func (e *transientThenOKExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]ggagent.SubAgentResult, len(reqs)) + for i, req := range reqs { + if strings.Contains(req.AgentID, "review") { + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"approved":true,"score":90,"summary":"ok"}`} + continue + } + e.workerCalls++ + if e.workerCalls == 1 { + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, Error: e.first} + continue + } + _ = os.WriteFile(filepath.Join(e.root, "a.go"), []byte("package main\n\nconst Greeting = \"hi\"\n"), 0o644) + out[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"status":"done","summary":"second try","files_changed":["a.go"]}`} + } + return out, nil +} + +func TestTransientWorkerErrorIsRetriedOnceBeforeBlocking(t *testing.T) { + newBoard := func() *plan.Board { + return &plan.Board{Tasks: []plan.Task{{ + ID: "T1", Title: "update the greeting", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + }}} + } + t.Run("connection refused is retried and the retry lands", func(t *testing.T) { + root := t.TempDir() + _ = os.WriteFile(filepath.Join(root, "a.go"), []byte("package main\n"), 0o644) + exec := &transientThenOKExec{root: root, first: errors.New( + "chat failed: Post \"http://127.0.0.1:1/v1/chat/completions\": dial tcp 127.0.0.1:1: connect: connection refused")} + if !backends.Classify(exec.first).Retryable() { + t.Fatal("fixture error is not classified as retryable") + } + r := defaultRunner(t, root, exec) + r.MaxRetries = 0 + board := newBoard() + if err := r.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + got, _ := board.Get("T1") + if got.Column != plan.ColDone { + t.Fatalf("T1 column=%s error=%q, want done after one transient retry", got.Column, got.Error) + } + if exec.workerCalls != 2 { + t.Fatalf("worker calls=%d, want 2", exec.workerCalls) + } + }) + t.Run("an unclassifiable error still blocks without a retry", func(t *testing.T) { + root := t.TempDir() + _ = os.WriteFile(filepath.Join(root, "a.go"), []byte("package main\n"), 0o644) + exec := &transientThenOKExec{root: root, first: errors.New("the model exploded")} + if backends.Classify(exec.first).Retryable() { + t.Fatal("fixture error is unexpectedly retryable") + } + r := defaultRunner(t, root, exec) + r.MaxRetries = 0 + board := newBoard() + _ = r.RunBoard(context.Background(), board) + got, _ := board.Get("T1") + if got.Column != plan.ColBlocked { + t.Fatalf("T1 column=%s, want blocked", got.Column) + } + if exec.workerCalls != 1 { + t.Fatalf("worker calls=%d, want 1 (no retry for a permanent failure)", exec.workerCalls) + } + }) +} + +func TestTransientRetryDelayHonorsRetryAfterWithinACap(t *testing.T) { + cases := []struct { + name string + c backends.Classification + want time.Duration + }{ + {"plain transient waits nothing", backends.Classification{Class: backends.ClassTransient}, 0}, + {"429 without a hint pauses briefly", backends.Classification{Class: backends.ClassRateLimited}, time.Second}, + {"Retry-After is honored", backends.Classification{Class: backends.ClassRateLimited, RetryAfter: 4 * time.Second}, 4 * time.Second}, + {"a long hint is capped", backends.Classification{Class: backends.ClassTransient, RetryAfter: 5 * time.Minute}, maxTransientRetryDelay}, + } + for _, tc := range cases { + if got := transientRetryDelay(tc.c); got != tc.want { + t.Errorf("%s: delay=%s want %s", tc.name, got, tc.want) + } + } +} + +// ── every dispatch is clamped to the runway ───────────────────────────────── + +// TestEveryDispatchTimeoutFitsTheRunway drives worker, reviewer and corrector +// under a 30-second run deadline with a 12-minute task timeout, and checks the +// timeout handed to the executor for EVERY request against what the runway +// allows once the finish reserve is held back. +func TestEveryDispatchTimeoutFitsTheRunway(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := &scriptedExec{answer: func(req ggagent.SubAgentRequest, _ int) string { + if strings.Contains(req.AgentID, "review") { + return `{"approved":false,"score":30,"summary":"no test was added"}` + } + return `{"status":"done","summary":"claims only","files_changed":["a.go"]}` + }} + r := defaultRunner(t, root, exec) + r.Timeout = 12 * time.Minute + r.MaxRetries = 1 + const runway = 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), runway) + defer cancel() + board := &plan.Board{Tasks: []plan.Task{{ + ID: "T1", Title: "update the greeting", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + }}} + _ = r.RunBoard(ctx, board) + + usable := runway - runway/loopFinishReserveDivisor + if exec.countFor(plan.RoleReviewer) == 0 || exec.countFor(plan.RoleCorrector) == 0 { + t.Fatalf("the fixture did not exercise review and correction: %d reviewer, %d corrector requests", + exec.countFor(plan.RoleReviewer), exec.countFor(plan.RoleCorrector)) + } + exec.mu.Lock() + defer exec.mu.Unlock() + for _, req := range exec.reqs { + if req.Timeout <= 0 || req.Timeout > usable { + t.Errorf("%s request timeout %s exceeds the %s the runway allows (task_timeout=%s)", + req.AgentID, req.Timeout, usable, r.Timeout) + } + } +} diff --git a/pkg/loop/reviewclip_test.go b/pkg/loop/reviewclip_test.go index 3c03bc3..3a87cb6 100644 --- a/pkg/loop/reviewclip_test.go +++ b/pkg/loop/reviewclip_test.go @@ -1,6 +1,8 @@ package loop import ( + "os" + "path/filepath" "strings" "testing" @@ -45,6 +47,53 @@ func TestReviewerAlwaysSeesHarnessEvidenceEvenWhenProseIsHuge(t *testing.T) { } } +// TestCorrectorAlwaysSeesHarnessEvidenceEvenWhenProseIsHuge mirrors the +// reviewer guard above for the CORRECTOR. formatCorrectPrompt clipped the +// previous output head-first at 2.5 KB while runGates appends the failing +// command's section and observation to the END of it — so a verbose worker +// meant the corrector was told to fix a smoke failure it was never shown. The +// prompt must carry the FAILED evidence, the focus files it is scoped to, the +// acceptance and the project language. +func TestCorrectorAlwaysSeesHarnessEvidenceEvenWhenProseIsHuge(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module demo\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + prose := strings.Repeat("The worker reasons at length about the change. ", 400) // ~19 KB + failed := quality.SmokeSectionHeader + "\n" + quality.SmokeFailedMarker + " cmd: go test ./...\n" + + "--- FAIL: TestMedian (0.00s)\n stats_test.go:12: got 2, want 3\n" + out := prose + "\n\n" + failed + if strings.Contains(truncate(out, correctorOutputBudget), quality.SmokeFailedMarker) { + t.Fatal("fixture no longer reproduces the defect — head clip kept the evidence") + } + + r := NewRunner(nil, nil) + r.Root = root + task := plan.Task{ + ID: "T1", Title: "Implement Median", Role: plan.RoleWorker, + Description: "Implement Median in stats.go.", Acceptance: "go test ./... passes", + Files: []string{"pkg/stats/stats.go", "pkg/stats/stats_test.go"}, + Output: out, + } + got := r.formatCorrectPrompt(task, plan.ReviewResult{ + Summary: "rejected: deterministic smoke failed", + Issues: []string{"deterministic smoke command failed — corrector must fix"}, + }) + for _, want := range []string{ + quality.SmokeSectionHeader, quality.SmokeFailedMarker, "--- FAIL: TestMedian", "got 2, want 3", + "## Focus files (HARD SCOPE)", "pkg/stats/stats.go", "pkg/stats/stats_test.go", + "## Acceptance\ngo test ./... passes", "## Project language\nProject language: Go.", + "## Review issues\n- deterministic smoke command failed", + } { + if !strings.Contains(got, want) { + t.Errorf("corrector prompt lost %q — it cannot fix a failure it cannot see", want) + } + } + if !strings.Contains(got, "The worker reasons") { + t.Error("corrector prompt dropped the previous answer entirely") + } +} + // TestClipForReviewLeavesShortOutputAlone pins the no-regression case: when // everything fits, the reviewer sees exactly what it saw before. func TestClipForReviewLeavesShortOutputAlone(t *testing.T) { diff --git a/pkg/loop/runner.go b/pkg/loop/runner.go index 48c7589..8207b29 100644 --- a/pkg/loop/runner.go +++ b/pkg/loop/runner.go @@ -132,6 +132,18 @@ type Runner struct { OnEventFull StructuredEvent OnUsage UsageEvent BuildInput BuildInput + // RoleTimeout returns the measured per-role budget for one request of + // role (an agent id; an escalation rung suffix is stripped before the + // lookup). The loop dispatches on min(callTimeout, RoleTimeout), so a + // reviewer that answers in 20s is given its measured floor rather than + // the whole task_timeout, and a stuck one is cut short accordingly. nil + // or a non-positive answer keeps the flat Timeout. + RoleTimeout func(ctx context.Context, role string) time.Duration + // OnRoleLatency reports how long one request of role took. evidence is + // true for a success or a timeout (a censored lower bound), false for + // any other failure — the same rule the orchestrator's phase roles use, + // so the samples land in the same latency memory on the same footing. + OnRoleLatency func(role string, d time.Duration, evidence bool) // ── contract with the orchestrator ────────────────────────────────────── @@ -1246,10 +1258,25 @@ func (r *Runner) dispatchWave(ctx context.Context, reqs []ggagent.SubAgentReques if len(reqs) == 0 { return nil, nil } + // Each worker request is dispatched on the measured budget for ITS role and + // contributes a latency sample under it — the base agent id, so an + // escalation rung and the role it escalated from share one measurement. + for i := range reqs { + r.applyRoleTimeout(ctx, &reqs[i]) + } if len(reqs) == 1 { defer r.streamTokens(reqs[0].AgentID, reqs[0].TaskID)() - return r.Executor.ExecuteSubAgents( + start := time.Now() + out, err := r.Executor.ExecuteSubAgents( r.agentCtx(ctx, reqs[0].TaskID, reqs[0].AgentID), reqs, r.Shared) + var first ggagent.SubAgentResult + if len(out) > 0 { + first = out[0] + } else { + first = ggagent.SubAgentResult{Error: err} + } + r.noteRoleLatency(reqs[0].AgentID, time.Since(start), err, first) + return out, err } results := make([]ggagent.SubAgentResult, len(reqs)) @@ -1264,14 +1291,16 @@ func (r *Runner) dispatchWave(ctx context.Context, reqs []ggagent.SubAgentReques // exactly what the terminal needs to keep four concurrent workers' // deltas apart. defer r.streamTokens(req.AgentID, req.TaskID)() + start := time.Now() out, err := r.Executor.ExecuteSubAgents( r.agentCtx(ctx, req.TaskID, req.AgentID), []ggagent.SubAgentRequest{req}, r.Shared) errs[i] = err if len(out) > 0 { results[i] = out[0] - return + } else { + results[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, Error: err} } - results[i] = ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, Error: err} + r.noteRoleLatency(req.AgentID, time.Since(start), err, results[i]) }(i) } wg.Wait() @@ -1307,7 +1336,11 @@ func (r *Runner) collectWaveResults(ctx context.Context, board *plan.Board, ws * t.Output = outputString(res) if isTimeoutResult(execErr, res) { - r.handleTaskTimeout(board, *t, role, res, timeoutErr(execErr, res)) + var budget time.Duration + if j < len(reqs) { + budget = reqs[j].Timeout + } + r.handleTaskTimeout(ctx, board, *t, role, res, timeoutErr(execErr, res), budget) continue } // Per-RESULT, not per-wave: execErr is the first error across the whole @@ -1394,41 +1427,38 @@ func (r *Runner) handleTaskCancel(board *plan.Board, t *plan.Task, role string, strings.Join(t.Files, ", "), t.Error, stream.LevelWarn) } -// recoverWaveError handles a failed worker result. A context overflow shrinks -// the pack and retries once; anything else blocks the task. The overflow test -// is backends.IsContextOverflow, which classifies the provider's error rather -// than pattern-matching a generic HTTP 400. +// recoverWaveError handles a failed worker result. +// +// The conversation is checkpointed FIRST, so every retry below resumes it via +// applyResumeRequest instead of cold-starting the task: the overflow retry used +// to save the checkpoint only after it had already re-dispatched, so the one +// attempt that most needed the transcript never had it. +// +// Then, by what backends.Classify says the error is: a context overflow shrinks +// the pack and retries once; a transient or rate-limited failure — the classes +// Classify marks Retryable — retries once after the server's Retry-After; +// anything else blocks the task. Classify reads the provider's own error +// rather than pattern-matching a generic HTTP 400. func (r *Runner) recoverWaveError(ctx context.Context, board *plan.Board, t *plan.Task, role string, res ggagent.SubAgentResult, execErr error) (ggagent.SubAgentResult, bool) { + if len(res.Messages) > 0 { + r.saveReactFromResult(t.ID, role, res) + } class := backends.Classify(res.Error) - overflow := backends.IsContextOverflow(res.Error) - if overflow && r.OnOverflowCompact != nil { - r.logf("%s context overflow (%s) — compacting and retrying once", t.ID, class.Class) - adv := r.noteFailure(evolve.Signal{ - Message: res.Error.Error(), Phase: "execute", Role: role, - Language: detectSignalLanguage(r.Root), - }, "") - if cerr := r.OnOverflowCompact(ctx); cerr != nil { - r.logf("%s overflow compact: %v", t.ID, cerr) - } else { - retryReq := ggagent.SubAgentRequest{ - AgentID: role, Input: r.taskInputFor(board, *t), Timeout: r.callTimeout(ctx), - ShareState: true, TaskID: t.ID, - } - if r.applyResumeRequest(&retryReq, t.ID) { - r.ResumedReact = true - } - retryRes, ok := r.execOne(ctx, t.ID, "overflow retry", retryReq) - if ok && retryRes.Error == nil && strings.TrimSpace(outputString(retryRes)) != "" { - r.noteResolved(adv, "context compacted, retried once") - return retryRes, true - } - if ok && retryRes.Error != nil { - r.logf("%s overflow retry still failed: %v", t.ID, retryRes.Error) - res = retryRes - } + switch { + case backends.IsContextOverflow(res.Error) && r.OnOverflowCompact != nil: + if retried, ok := r.retryAfterOverflow(ctx, board, t, role, res, class); ok { + return retried, true + } else if retried.Error != nil { + res = retried + } + case res.Error != nil && class.Retryable() && !r.runwaySpent(ctx): + if retried, ok := r.retryAfterTransient(ctx, board, t, role, res, class); ok { + return retried, true + } else if retried.Error != nil { + res = retried } - } else if res.Error != nil { + case res.Error != nil: r.noteFailure(evolve.Signal{ Message: res.Error.Error(), Phase: "execute", Role: role, Language: detectSignalLanguage(r.Root), @@ -1453,6 +1483,100 @@ func (r *Runner) recoverWaveError(ctx context.Context, board *plan.Board, t *pla return res, false } +// retryAfterOverflow compacts the task's context and re-dispatches the worker +// once. It returns the retry's result and whether it produced a usable answer; +// a retry that failed again is returned with its own error so the caller +// blocks the task on the fresher failure. +func (r *Runner) retryAfterOverflow(ctx context.Context, board *plan.Board, t *plan.Task, + role string, res ggagent.SubAgentResult, class backends.Classification) (ggagent.SubAgentResult, bool) { + r.logf("%s context overflow (%s) — compacting and retrying once", t.ID, class.Class) + adv := r.noteFailure(evolve.Signal{ + Message: res.Error.Error(), Phase: "execute", Role: role, + Language: detectSignalLanguage(r.Root), + }, "") + if cerr := r.OnOverflowCompact(ctx); cerr != nil { + r.logf("%s overflow compact: %v", t.ID, cerr) + return res, false + } + retryRes, ok := r.redispatch(ctx, board, t, role, "overflow retry") + if ok && retryRes.Error == nil && strings.TrimSpace(outputString(retryRes)) != "" { + r.noteResolved(adv, "context compacted, retried once") + return retryRes, true + } + if ok && retryRes.Error != nil { + r.logf("%s overflow retry still failed: %v", t.ID, retryRes.Error) + return retryRes, false + } + return res, false +} + +// maxTransientRetryDelay caps the wait a Retry-After hint can impose before the +// one transient retry: a hint of minutes would spend the runway on waiting. +const maxTransientRetryDelay = 30 * time.Second + +// transientRetryDelay is how long to wait before the transient retry: the +// server's Retry-After when it sent one, a short pause for a 429 that sent +// none, nothing for a plain connection failure. +func transientRetryDelay(c backends.Classification) time.Duration { + wait := c.RetryAfter + if wait <= 0 && c.Class == backends.ClassRateLimited { + wait = time.Second + } + if wait > maxTransientRetryDelay { + wait = maxTransientRetryDelay + } + return wait +} + +// retryAfterTransient re-dispatches a worker ONCE after a transport-level or +// rate-limit failure, honoring the server's Retry-After. +// +// A local inference server that has just finished loading a model refuses +// connections for a few seconds; a shared endpoint answers 429 under load. The +// old path classified both and then blocked the task anyway, cascading the +// block to every dependent, over a failure the classifier had already said was +// worth one more attempt. +func (r *Runner) retryAfterTransient(ctx context.Context, board *plan.Board, t *plan.Task, + role string, res ggagent.SubAgentResult, class backends.Classification) (ggagent.SubAgentResult, bool) { + r.logf("%s %s failure (%v) — retrying once", t.ID, class.Class, res.Error) + adv := r.noteFailure(evolve.Signal{ + Message: res.Error.Error(), Phase: "execute", Role: role, + Language: detectSignalLanguage(r.Root), + }, "") + if wait := transientRetryDelay(class); wait > 0 { + r.logf("%s waiting %s before the retry (Retry-After)", t.ID, wait) + select { + case <-ctx.Done(): + return res, false + case <-time.After(wait): + } + } + retryRes, ok := r.redispatch(ctx, board, t, role, "transient retry") + if ok && retryRes.Error == nil && strings.TrimSpace(outputString(retryRes)) != "" { + r.noteResolved(adv, "transient failure, retried once") + return retryRes, true + } + if ok && retryRes.Error != nil { + r.logf("%s transient retry still failed: %v", t.ID, retryRes.Error) + return retryRes, false + } + return res, false +} + +// redispatch sends a task to its worker again, resuming from the ReAct +// checkpoint when one exists, under the runway-clamped call budget. +func (r *Runner) redispatch(ctx context.Context, board *plan.Board, t *plan.Task, role, what string) ( + ggagent.SubAgentResult, bool) { + req := ggagent.SubAgentRequest{ + AgentID: role, Input: r.taskInputFor(board, *t), Timeout: r.callTimeout(ctx), + ShareState: true, TaskID: t.ID, + } + if r.applyResumeRequest(&req, t.ID) { + r.ResumedReact = true + } + return r.execOne(ctx, t.ID, what, req) +} + // driveCritique refines every weak task, in parallel when there is more than one. func (r *Runner) driveCritique(ctx context.Context, board *plan.Board, ws *waveState) { if len(ws.weak) == 0 { @@ -1507,7 +1631,20 @@ func (r *Runner) driveReview(ctx context.Context, board *plan.Board, ws *waveSta return canceled } -func (r *Runner) handleTaskTimeout(board *plan.Board, t plan.Task, role string, res ggagent.SubAgentResult, err error) { +// handleTaskTimeout handles a worker that ran out of its per-call budget. +// +// budget is the timeout the request was actually dispatched with — the +// runway-clamped callTimeout, not r.Timeout — so the message says how long the +// worker really had. +// +// A timed-out worker is not a failed one: measured, it is most often a worker +// midway through a long tool loop whose checkpoint is already on disk. Under +// the attempt ceiling and with runway left, the task goes back to ready_to_dev +// and the next wave RESUMES it from that checkpoint (applyResumeRequest) +// instead of parking it for a human on the first attempt. Only at the ceiling, +// or with no runway left to spend on it, does it park in to_scope. +func (r *Runner) handleTaskTimeout(ctx context.Context, board *plan.Board, t plan.Task, role string, + res ggagent.SubAgentResult, err error, budget time.Duration) { if err == nil { err = context.DeadlineExceeded } @@ -1516,11 +1653,33 @@ func (r *Runner) handleTaskTimeout(board *plan.Board, t plan.Task, role string, } scope := strings.Join(t.Files, ", ") baseOut := strings.TrimSpace(outputString(res)) - after := r.Timeout - if after <= 0 { - after = 12 * time.Minute + if budget <= 0 { + budget = r.callTimeout(ctx) + } + if budget <= 0 { + budget = 12 * time.Minute + } + timeoutMsg := fmt.Sprintf("task timed out after %s: %s", budget.Round(time.Second), err.Error()) + + attempt := r.waveAttempts.get(t.ID) + ceiling := r.maxTaskAttempts() + if attempt < ceiling && !r.runwaySpent(ctx) { + note := fmt.Sprintf("RETRY-AFTER-TIMEOUT: attempt %d timed out after %s; re-queued to resume "+ + "from the ReAct checkpoint (attempt %d of %d).", + attempt, budget.Round(time.Second), attempt+1, ceiling) + t.Output = strings.TrimSpace(baseOut + "\n\n## Harness timeout\n" + note) + t.Error = "" + t.Notes = strings.TrimSpace(t.Notes + "\n" + note) + t.MoveTo(plan.ColReadyToDev) + board.UpdateTask(t) + r.persist(board) + r.logf("%s %s", t.ID, note) + r.fireLevel(stream.KindAgentEnd, role, t.ID, + fmt.Sprintf("timed out — re-queued to resume (attempt %d/%d)", attempt+1, ceiling), + scope, timeoutMsg, stream.LevelWarn) + return } - timeoutMsg := fmt.Sprintf("task timed out after %s: %s", after.Round(time.Second), err.Error()) + if baseOut == "" { t.Output = fmt.Sprintf(`{"status":"blocked","summary":%q,"files_changed":[],"notes":"harness timeout; retry with smaller scope or lower concurrency"}`, timeoutMsg) } else { @@ -1593,7 +1752,8 @@ func (r *Runner) fireTurn(taskID string, iter, maxIter int) { // noteUsage is the single choke point every agent result passes through, which // makes it the right place to fold the transcript into the edit ledger too — -// wave results, sequential round-trips and the speculative race all land here. +// wave results, review and correction round-trips and the speculate primitive +// all land here. func (r *Runner) noteUsage(res ggagent.SubAgentResult, input, output string) { r.noteEdits(res.Messages) if r.OnUsage == nil { @@ -1795,10 +1955,9 @@ func (r *Runner) gatherGateSignals(ctx context.Context, current *plan.Task, base return g } -// decideReview produces the review verdict: the disk fast path, the speculative -// race, or a single reviewer LLM call. -func (r *Runner) decideReview(ctx context.Context, current plan.Task, g gateState, - baseline map[string]string) (plan.ReviewResult, string, error) { +// decideReview produces the review verdict: the disk fast path, the harness +// fast-reject, or the reviewer LLM. +func (r *Runner) decideReview(ctx context.Context, current plan.Task, g gateState) (plan.ReviewResult, string, error) { if g.fastPath(current.Role) { review := plan.ReviewResult{Approved: true, Score: 85} switch { @@ -1812,168 +1971,173 @@ func (r *Runner) decideReview(ctx context.Context, current plan.Task, g gateStat r.logf("%s review fast-path (skip reviewer LLM): %s", current.ID, review.Summary) return review, "", nil } - if r.MaxParallel >= 2 { - return r.speculativeReview(ctx, current, g, baseline) - } - return r.plainReview(ctx, current, g) -} - -// reviewerStrictDelay gives the primary reviewer a head start before the -// strict second reviewer is even dispatched. A real LLM essentially never -// answers this fast, so production still fans out to both reviewers exactly -// as before; the delay only matters against a fast/local answer (disk -// evidence, a quick model, a test double), where it lets the race actually -// skip the second slot instead of firing it and discarding the result. -const reviewerStrictDelay = 20 * time.Millisecond - -// speculativeReview races a disk/acceptance probe against the reviewer LLM (and -// a strict second reviewer when capacity allows). -func (r *Runner) speculativeReview(ctx context.Context, current plan.Task, g gateState, - baseline map[string]string) (plan.ReviewResult, string, error) { - // One race is one review attempt against the task's budget, however many - // speculative paths it fans out to. Without this the DEFAULT configuration - // (MaxParallel=4, which always takes this branch) would never be budgeted - // at all — reviews are exactly where the round-trip ladder lives. - if !r.spend(current.ID, "review") { - return r.slmApprovalFallback(plan.ReviewResult{ - Approved: false, Summary: "review skipped: per-task call budget exhausted", - }, current, g, ""), "", nil + if review, ok := g.fastReject(current); ok { + r.logf("%s review fast-reject (skip reviewer LLM): %s", current.ID, review.Summary) + return review, "", nil } - reviewIn := r.formatReviewPrompt(current) - revRole := r.resolveRole(r.reviewerID()) - slots := r.reviewSlots(current, g, baseline, reviewIn, revRole) - r.fire(stream.KindAgentStart, revRole, current.ID, "speculative review race", - strings.Join(current.Files, ", "), "") - // Budget honesty: the race costs ONE unit but issues one real LLM request - // per non-local slot — reviewer plus reviewer-strict at the default - // max_parallel=4. Against a server that runs inference serially a 10-unit - // budget is therefore up to ~13 round-trips, and the operator waits on the - // round-trips. spend() already counted one; record the rest so the budget - // event reports both numbers instead of understating the wait. - llmSlots := 0 - for _, sl := range slots { - if sl.Local == nil { - llmSlots++ - } - } - if llmSlots > 1 { - r.noteExtraRequests(current.ID, llmSlots-1) - } - r.logf("%s speculative review (%d paths, %d LLM requests, 1 budget unit, max_parallel=%d)", - current.ID, len(slots), llmSlots, r.MaxParallel) - res := r.speculate(r.taskCtx(ctx, current.ID), slots) - - // Only a slot that RAN TO COMPLETION carries a verdict. A racer the winner - // canceled comes back Skipped (see speculate), and a streaming one can - // come back with a partial body: reading `{"approved":true,"score":92,` off - // a cut-short stream and calling it a verdict is how the winner's complete - // approval was rendered and acted on as `approved=false score=0`, costing a - // correction round the model never needed. - var acceptOut, revOut, strictOut string - var revErr error - for _, sr := range res { - done := !sr.Skipped && sr.Err == nil && strings.TrimSpace(sr.Output) != "" - switch sr.Role { - case "acceptance": - if done { - acceptOut = sr.Output - } - case revRole: - revErr = sr.Err - if done { - revOut = sr.Output - } - default: - if done { - strictOut = sr.Output - } - } + return r.llmReview(ctx, current, g) +} + +// fastReject reports a rejection the harness can already state without asking +// a reviewer, so no LLM round-trip is spent on a verdict that could only come +// out one way. +// +// Two cases. A hard gate fired: applyHardGates overturns any approval the +// reviewer gives in that state (same role and rename exemptions), so the +// reviewer's answer could not change the outcome — it could only cost a +// request. Or the worker's own finalize says it is blocked and nothing on disk +// contradicts it: the worker has already said, in its own words, what a +// rejection would say. +// +// Testers are exempt for the reason applyHardGates exempts them: a tester's +// failing command IS its finding, not a defect in its work. +func (g gateState) fastReject(current plan.Task) (plan.ReviewResult, bool) { + if plan.IsTesterRole(current.Role) || g.renameDisk { + return plan.ReviewResult{}, false + } + if g.blocking() { + summary, issue := g.rejectReason() + return plan.ReviewResult{Approved: false, Score: 20, Summary: summary, Issues: []string{issue}}, true + } + if why, ok := workerReportedBlocked(current.Output); ok && !g.hasEvidence() && !g.satisfied { + return plan.ReviewResult{ + Approved: false, Score: 10, + Summary: "rejected: worker reported blocked — " + why, + Issues: []string{why}, + }, true } - if strings.TrimSpace(acceptOut) != "" { - r.logf("%s review acceptance won — canceled reviewer LLM", current.ID) - return plan.ParseReviewJSON(acceptOut), acceptOut, nil + return plan.ReviewResult{}, false +} + +// workerReportedBlocked reads a `{"status":"blocked", …}` finalize out of the +// model's half of a task output and returns the worker's own summary. +func workerReportedBlocked(output string) (string, bool) { + raw := strings.TrimSpace(stripPostSections(output)) + if raw == "" { + return "", false } - reviewRaw := revOut - if strings.TrimSpace(reviewRaw) == "" { - reviewRaw = strictOut + fixed, _, err := repair.RepairRole(raw, plan.RoleWorker) + if err != nil { + return "", false } - if revErr != nil && strings.TrimSpace(reviewRaw) == "" { - return plan.ReviewResult{}, "", revErr + var st struct { + Status string `json:"status"` + Summary string `json:"summary"` } - review := r.parseReviewOutput(ctx, current, reviewRaw, revRole, reviewIn) - return r.slmApprovalFallback(review, current, g, reviewRaw), reviewRaw, nil + if json.Unmarshal(fixed, &st) != nil || !strings.EqualFold(strings.TrimSpace(st.Status), "blocked") { + return "", false + } + why := strings.TrimSpace(st.Summary) + if why == "" { + why = "worker reported status blocked without a summary" + } + return why, true } -// reviewSlots builds the speculative review race: a local disk/acceptance probe, -// the reviewer LLM, and — when capacity allows — a strict second reviewer. -func (r *Runner) reviewSlots(current plan.Task, g gateState, baseline map[string]string, - reviewIn, revRole string) []SpecSlot { - cur := current - base := baseline - // Use a shorter reviewer timeout when disk evidence already exists — the - // acceptance probe should win quickly; the full 12 min is excessive. - revTimeout := r.Timeout - if g.hasEvidence() { - revTimeout = 3 * time.Minute - } - slots := []SpecSlot{{ - Role: "acceptance", Required: false, - Local: func(ctx context.Context) (string, error) { - return acceptanceProbe(ctx, func() bool { - return renameOK(r.Root, cur) || alreadySatisfied(r.Root, cur, base) || - r.hasRealWriteEvidence(cur, base) - }, `{"approved":true,"score":85,"summary":"auto-approved: acceptance race won"}`) - }, - }, { - Role: revRole, Prompt: reviewIn, Required: false, Timeout: revTimeout, - }} - // Second reviewer when capacity allows. reviewer-strict went unregistered - // for as long as this code referenced it, so the path never ran; assert the - // role is real instead of failing silently at runtime. - if r.MaxParallel >= 3 { - if strict, ok := r.resolveBuiltinSlot(roleReviewerStrict); ok { - slots = append(slots, SpecSlot{ - Role: strict, Required: false, - Prompt: reviewIn + "\n\nSTRICT: reject unless focus files + acceptance clearly met. Return JSON.", - Timeout: revTimeout, - // A genuine head start for the primary reviewer: a real LLM - // almost never answers inside this window, so production - // still races both reviewers as before. But it means the - // strict slot's dispatch — and its real budget/wall-clock - // cost — is actually skippable, not just cancelable after - // the fact, whenever the primary reviewer is fast enough to - // have already decided the race. - Delay: reviewerStrictDelay, - }) - } - } - return slots -} - -// plainReview runs one reviewer LLM call. -func (r *Runner) plainReview(ctx context.Context, current plan.Task, g gateState) ( +// reviewEvidenceTimeout caps a reviewer call once disk evidence already exists: +// the verdict is then mostly confirmation, and the full task timeout is +// excessive for it. +const reviewEvidenceTimeout = 3 * time.Minute + +// reviewTimeout is the per-call budget for a reviewer dispatch: the runway clamp +// first — a review that outlives the run kills the finish path exactly as a +// worker that does — then the evidence cap. +func (r *Runner) reviewTimeout(ctx context.Context, g gateState) time.Duration { + t := r.callTimeout(ctx) + if g.hasEvidence() && t > reviewEvidenceTimeout { + return reviewEvidenceTimeout + } + return t +} + +// llmReview asks the reviewer for a verdict: ONE request, and a strict second +// opinion only when the first came back empty or unreadable. +// +// This used to be a speculative race. At the default max_parallel it fanned +// out to a local "acceptance" probe, the reviewer and reviewer-strict all at +// once, then read them in that order — two costs and one hole: +// +// - reviewer-strict was dispatched on EVERY review and its answer used only +// when the primary's was empty, so at max_parallel>=3 each review cost two +// full reviewer prefills for one verdict; +// - the acceptance probe re-tested a subset of fastPath's conditions with +// none of its guards — no criteriaOpen, no scope check, no blocking gates, +// no tester exemption — so a task with an open must-criterion and a disk +// write could be approved "acceptance race won" with no reviewer at all, +// right after fastPath had declined to approve it for exactly that reason. +// +// Only decideReview's fastPath approves without a reviewer now, and the second +// reviewer runs only in the one case its answer was ever used. Every dispatch +// goes through execOne, so the runway and budget checks apply here as they do +// to every other loop-side call. +func (r *Runner) llmReview(ctx context.Context, current plan.Task, g gateState) ( plan.ReviewResult, string, error) { revRole := r.resolveRole(r.reviewerID()) r.fire(stream.KindAgentStart, revRole, current.ID, "self-critic review", strings.Join(current.Files, ", "), "") reviewIn := r.formatReviewPrompt(current) res, ok := r.execOne(ctx, current.ID, "review", ggagent.SubAgentRequest{ - AgentID: revRole, Input: reviewIn, Timeout: r.callTimeout(ctx), ShareState: true, TaskID: current.ID, + AgentID: revRole, Input: reviewIn, Timeout: r.reviewTimeout(ctx, g), + ShareState: true, TaskID: current.ID, }) if !ok { - // Budget exhausted: approve on evidence or reject — never loop. + // Out of budget or out of runway: approve on evidence or reject — never loop. return r.slmApprovalFallback(plan.ReviewResult{ - Approved: false, Summary: "review skipped: per-task call budget exhausted", + Approved: false, Summary: r.reviewSkippedSummary(ctx), }, current, g, ""), "", nil } reviewRaw := outputString(res) - if res.Error != nil && reviewRaw == "" { + if res.Error != nil && strings.TrimSpace(reviewRaw) == "" { return plan.ReviewResult{}, "", res.Error } review := r.parseReviewOutput(ctx, current, reviewRaw, revRole, reviewIn) + if review.NoVerdict { + if second, raw, ok := r.strictSecondOpinion(ctx, current, g, reviewIn); ok { + review, reviewRaw = second, raw + } + } return r.slmApprovalFallback(review, current, g, reviewRaw), reviewRaw, nil } +// reviewSkippedSummary names why a review was not dispatched at all. +func (r *Runner) reviewSkippedSummary(ctx context.Context) string { + if r.runwaySpent(ctx) { + return "review skipped: out of runway" + } + return "review skipped: per-task call budget exhausted" +} + +// strictSecondOpinion asks reviewer-strict when the primary reviewer produced +// no readable verdict. Sequential, not raced: it is one more real request +// against the task's budget, spent only in the case its answer is used. +func (r *Runner) strictSecondOpinion(ctx context.Context, current plan.Task, g gateState, + reviewIn string) (plan.ReviewResult, string, bool) { + strict, ok := r.resolveBuiltinSlot(roleReviewerStrict) + if !ok { + return plan.ReviewResult{}, "", false + } + r.logf("%s primary reviewer produced no verdict — asking %s once", current.ID, strict) + r.fire(stream.KindAgentStart, strict, current.ID, "strict second opinion", + strings.Join(current.Files, ", "), "") + strictIn := reviewIn + "\n\nSTRICT: reject unless focus files + acceptance clearly met. Return JSON." + res, ok := r.execOne(ctx, current.ID, "review (strict second opinion)", ggagent.SubAgentRequest{ + AgentID: strict, Input: strictIn, Timeout: r.reviewTimeout(ctx, g), + ShareState: true, TaskID: current.ID, + }) + if !ok { + return plan.ReviewResult{}, "", false + } + raw := outputString(res) + if strings.TrimSpace(raw) == "" { + return plan.ReviewResult{}, "", false + } + review := r.parseReviewOutput(ctx, current, raw, strict, strictIn) + if review.NoVerdict { + return plan.ReviewResult{}, "", false + } + return review, raw, true +} + // parseReviewOutput repairs the reviewer's JSON against the reviewer schema. // A response cut off by max_tokens is re-asked with a bigger budget rather than // answered with a corrector round-trip at what is only a truncation. @@ -2316,10 +2480,14 @@ func (r *Runner) reviewAndCorrectTask(ctx context.Context, t plan.Task, baseline } g := r.gatherGateSignals(ctx, ¤t, baseline) - review, reviewRaw, err := r.decideReview(ctx, current, g, baseline) + review, reviewRaw, err := r.decideReview(ctx, current, g) if err != nil { - // The reviewer never produced a verdict. That is still an attempt - // that happened, and the next one must be able to see it. + review, reviewRaw, err = r.recoverReviewError(ctx, current, g, err) + } + if err != nil { + // The reviewer never produced a verdict, and neither the evidence + // nor a second ask could stand in for one. That is still an + // attempt that happened, and the next one must be able to see it. // // NoVerdict is set for the same reason parseReviewOutput sets it on // an empty reply: without it this result is byte-identical to a @@ -2327,12 +2495,8 @@ func (r *Runner) reviewAndCorrectTask(ctx context.Context, t plan.Task, baseline // TIMED OUT would be read as a reviewer that judged the work // worthless. The distinction has to survive as far as the ledger, // because that is what the next attempt reads. - lin.record(current, g, plan.ReviewResult{ - Summary: err.Error(), Issues: []string{err.Error()}, NoVerdict: true, - }, plan.AttemptError, startedAt) - current.MoveTo(plan.ColBlocked) - current.Error = err.Error() - return current, nil, err + lin.record(current, g, review, plan.AttemptError, startedAt) + return r.escalateReviewerUnavailable(current, err) } review = r.applyHardGates(¤t, review, g, baseline) @@ -2532,6 +2696,59 @@ func (r *Runner) escalateTask(current plan.Task, review plan.ReviewResult, attem return current, &escalation{detail: detail, attempt: attempt, review: review}, nil } +// recoverReviewError handles a reviewer that returned a transport error instead +// of a verdict. +// +// It used to block the task outright — and PropagateBlocked then cascaded the +// block to every dependent — over an error that says nothing about the work: +// the reviewer's endpoint hiccupped after the worker had finished and every +// gate had run, and the gathered gate signals were thrown away with it. +// +// The harness evidence is judged first, exactly as it is for a reviewer whose +// reply could not be read: a task with disk write evidence and a clean gate +// pass is approved on that evidence. When the evidence does not carry it and +// the budget and runway allow, the reviewer is asked ONCE more. Only when that +// fails too does the task leave the ladder — and it goes to the human backlog +// as "reviewer unavailable", not to blocked as a failure of the work. +func (r *Runner) recoverReviewError(ctx context.Context, current plan.Task, g gateState, err error) ( + plan.ReviewResult, string, error) { + r.logf("%s reviewer unavailable (%v) — judging on harness evidence first", current.ID, err) + noVerdict := plan.ReviewResult{Summary: err.Error(), Issues: []string{err.Error()}, NoVerdict: true} + if review := r.slmApprovalFallback(noVerdict, current, g, ""); review.Approved { + return review, "", nil + } + if r.runwaySpent(ctx) || r.budgetExhausted(current.ID) { + return noVerdict, "", err + } + r.logf("%s re-asking the reviewer once after a transport error", current.ID) + again, raw, err2 := r.llmReview(ctx, current, g) + if err2 != nil { + return plan.ReviewResult{Summary: err2.Error(), Issues: []string{err2.Error()}, NoVerdict: true}, "", err2 + } + return again, raw, nil +} + +// escalateReviewerUnavailable parks a task whose reviewer could not be reached +// in the human backlog. +// +// The work is not known to be wrong — nothing judged it — so it must not land +// in blocked, where it reads as a failure of the work and PropagateBlocked +// strands every dependent behind it. to_scope is human-owned: dependents stay +// where they are, and promoting the task back to ready_to_dev reviews it again. +// The error is still returned so the caller reports the failure and does not +// hand the task to another specialist as if a reviewer had rejected it. +func (r *Runner) escalateReviewerUnavailable(current plan.Task, err error) (plan.Task, *escalation, error) { + why := "reviewer unavailable: " + err.Error() + current.MoveTo(plan.ColToScope) + current.Error = why + current.Notes = strings.TrimSpace(current.Notes + "\nESCALATED: reviewer unavailable (" + err.Error() + + "). The work was not judged — check the model endpoint, then promote back to ready_to_dev to review it again.") + r.logf("%s escalated to to_scope: %s", current.ID, why) + r.fireIntervention(current.ID, "escalate", + fmt.Sprintf("%s needs human review — reviewer unavailable", current.ID), why) + return current, nil, err +} + // rootDir is the nil-safe project root. func (r *Runner) rootDir() string { if r == nil { diff --git a/pkg/loop/runner_test.go b/pkg/loop/runner_test.go index 8092d95..efe5a3b 100644 --- a/pkg/loop/runner_test.go +++ b/pkg/loop/runner_test.go @@ -153,6 +153,10 @@ func TestRunBoardTimeoutsDoNotInterruptOrLeaveTasksInProgress(t *testing.T) { r.IdleWait = time.Millisecond r.PostWorkerSmoke = false r.RequireSmoke = false + // A timed-out worker is re-queued to resume from its checkpoint while it + // is under the attempt ceiling (see TestTimedOutWorkerIsResumedNotParked); + // this test pins the park path, so the ceiling is one attempt. + r.MaxTaskAttempts = 1 r.FailureHandler = NewEnhancedFailureHandler(root) var interventions int diff --git a/pkg/loop/speculate.go b/pkg/loop/speculate.go index 3fada43..d350dbe 100644 --- a/pkg/loop/speculate.go +++ b/pkg/loop/speculate.go @@ -141,8 +141,11 @@ func (r *Runner) speculate(ctx context.Context, slots []SpecSlot) []SpecResult { if r.Executor == nil { err = fmt.Errorf("nil executor") } else { - slotTimeout := r.Timeout - if j.slot.Timeout > 0 { + // Runway-clamped like every other dispatch: a slot + // that outlives the run takes the finish path with + // it. A per-slot Timeout only ever shortens that. + slotTimeout := r.callTimeout(gctx) + if j.slot.Timeout > 0 && j.slot.Timeout < slotTimeout { slotTimeout = j.slot.Timeout } // The racing reviewers stream too; the task id rides on @@ -197,19 +200,3 @@ func (r *Runner) speculate(ctx context.Context, slots []SpecSlot) []SpecResult { } return results } - -// acceptanceProbe repeatedly checks a predicate so disk acceptance can cancel a -// slower reviewer/tester LLM mid-flight. -func acceptanceProbe(ctx context.Context, win func() bool, approveJSON string) (string, error) { - for i := 0; i < 8; i++ { - if win() { - return approveJSON, nil - } - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(30 * time.Millisecond): - } - } - return "", fmt.Errorf("no acceptance") -} diff --git a/pkg/loop/speculate_cancel_test.go b/pkg/loop/speculate_cancel_test.go index a569618..9a216be 100644 --- a/pkg/loop/speculate_cancel_test.go +++ b/pkg/loop/speculate_cancel_test.go @@ -123,23 +123,25 @@ func TestSpeculateReportsARealInterrupt(t *testing.T) { } } -// TestSpeculativeReviewKeepsTheWinnersVerdict is the regression net for -// defect 2. The reviewer's stream is cut short by the winner and comes back as -// a truncated `{"approved":true,"score":92,"summ`. That is not a verdict — -// preferring it over the strict reviewer's COMPLETE approval is how an -// approved:true score:92 payload was rendered and acted on as -// `approved=false score=0`, buying a correction round nobody needed. -func TestSpeculativeReviewKeepsTheWinnersVerdict(t *testing.T) { +// TestReviewKeepsTheStrictVerdictWhenThePrimarySaysNothing is the regression +// net for defect 2 under the sequential contract. The primary reviewer answers +// nothing; the strict second opinion is a COMPLETE approval. That verdict must +// be the one acted on — dropping it is how an approved:true score:92 payload +// was rendered and acted on as `approved=false score=0`, buying a correction +// round nobody needed. +func TestReviewKeepsTheStrictVerdictWhenThePrimarySaysNothing(t *testing.T) { exec := &streamingRaceExec{ delay: map[string]time.Duration{ - plan.RoleReviewer: 2 * time.Second, + plan.RoleReviewer: 2 * time.Millisecond, roleReviewerStrict: 2 * time.Millisecond, }, - full: map[string]string{roleReviewerStrict: `{"approved":true,"score":92,"summary":"acceptance met"}`}, - partial: map[string]string{plan.RoleReviewer: `{"approved":true,"score":92,"summ`}, + full: map[string]string{ + plan.RoleReviewer: "", + roleReviewerStrict: `{"approved":true,"score":92,"summary":"acceptance met"}`, + }, } r := NewRunner(exec, ggagent.NewSharedState()) - r.MaxParallel = 4 // the shipped default; >=3 also arms the strict slot + r.MaxParallel = 4 // the shipped default r.Timeout = 30 * time.Second r.Log = func(string, ...interface{}) {} @@ -149,16 +151,16 @@ func TestSpeculativeReviewKeepsTheWinnersVerdict(t *testing.T) { Files: []string{"a.go"}, Output: `{"status":"done","summary":"x","files_changed":["a.go"]}`, } - review, raw, err := r.speculativeReview(context.Background(), cur, gateState{}, map[string]string{}) + review, raw, err := r.llmReview(context.Background(), cur, gateState{}) if err != nil { - t.Fatalf("speculativeReview: %v", err) + t.Fatalf("llmReview: %v", err) } if !review.Approved || review.Score != 92 { - t.Fatalf("winner's verdict dropped: approved=%v score=%d raw=%q", + t.Fatalf("strict verdict dropped: approved=%v score=%d raw=%q", review.Approved, review.Score, raw) } - if strings.Contains(raw, `"summ`) && !strings.Contains(raw, `"summary"`) { - t.Fatalf("the truncated loser's body was used as the verdict: %q", raw) + if !strings.Contains(raw, "acceptance met") { + t.Fatalf("the raw verdict is not the strict reviewer's: %q", raw) } } diff --git a/pkg/loop/speculate_test.go b/pkg/loop/speculate_test.go index 5b50941..1a0140d 100644 --- a/pkg/loop/speculate_test.go +++ b/pkg/loop/speculate_test.go @@ -38,7 +38,14 @@ func (f *fakeReviewExec) ExecuteSubAgents(ctx context.Context, reqs []ggagent.Su return out, nil } -func TestSpeculateReviewCancelsOnAcceptanceWin(t *testing.T) { +// TestSpeculateLocalWinnerCancelsSlowerSlot exercises the speculate primitive: +// a required local slot that answers first cancels the slower LLM slot. +// +// The review path no longer races a local "acceptance" probe against the +// reviewer — that probe re-tested a subset of fastPath's conditions with none +// of its guards, so it could approve a task fastPath had just declined. The +// primitive itself is unchanged and still has to cancel losers correctly. +func TestSpeculateLocalWinnerCancelsSlowerSlot(t *testing.T) { fe := &fakeReviewExec{ delay: map[string]time.Duration{ plan.RoleReviewer: 400 * time.Millisecond, @@ -54,13 +61,11 @@ func TestSpeculateReviewCancelsOnAcceptanceWin(t *testing.T) { Timeout: time.Minute, Log: func(string, ...interface{}) {}, } - ready := make(chan struct{}) - close(ready) slots := []SpecSlot{ { - Role: "acceptance", Required: true, + Role: "probe", Required: true, Local: func(ctx context.Context) (string, error) { - return `{"approved":true,"score":85,"summary":"auto-approved: acceptance race won"}`, nil + return `{"approved":true,"score":85,"summary":"local probe won"}`, nil }, }, {Role: plan.RoleReviewer, Prompt: "review", Required: false}, @@ -70,17 +75,17 @@ func TestSpeculateReviewCancelsOnAcceptanceWin(t *testing.T) { if time.Since(start) > 350*time.Millisecond { t.Fatalf("expected early cancel; elapsed=%s", time.Since(start)) } - var acc, rev SpecResult + var probe, rev SpecResult for _, sr := range res { switch sr.Role { - case "acceptance": - acc = sr + case "probe": + probe = sr case plan.RoleReviewer: rev = sr } } - if acc.Err != nil || acc.Output == "" { - t.Fatalf("acceptance: %+v", acc) + if probe.Err != nil || probe.Output == "" { + t.Fatalf("probe: %+v", probe) } if !rev.Skipped && rev.Err == nil && rev.Output != "" { t.Fatalf("expected reviewer canceled/skipped, got %+v", rev) diff --git a/pkg/loop/wiring_test.go b/pkg/loop/wiring_test.go index bd97119..179634b 100644 --- a/pkg/loop/wiring_test.go +++ b/pkg/loop/wiring_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/UnicoLab/slmcode/pkg/evolve" "github.com/UnicoLab/slmcode/pkg/memory" @@ -350,41 +351,71 @@ func TestResolveRoleAndBuiltinSlot(t *testing.T) { } } -// TestStrictReviewerSlotIsOfferedAtCapacity asserts the second reviewer is now -// really part of the race. speculate.go referenced "reviewer-strict" while no -// such agent was registered, so every strict slot returned -// "subagent 'reviewer-strict' not found" and the path never once ran. -func TestStrictReviewerSlotIsOfferedAtCapacity(t *testing.T) { - r := &Runner{Log: func(string, ...interface{}) {}} +// TestStrictReviewerIsASequentialSecondOpinion pins the cost of a review. +// +// The review used to be a race: at max_parallel>=3 it dispatched reviewer AND +// reviewer-strict with the same prompt on EVERY review, and read the strict +// answer only when the primary's was empty — two full reviewer prefills for +// one verdict, on a server that runs inference serially. The strict reviewer +// is now asked sequentially, and only in the one case its answer was ever +// used: the primary said nothing readable. +func TestStrictReviewerIsASequentialSecondOpinion(t *testing.T) { + task := plan.Task{ + ID: "T1", Title: "Update a", Role: plan.RoleWorker, + Description: "update the greeting in a.go", Acceptance: "greeting updated", + Files: []string{"a.go"}, + Output: `{"status":"done","summary":"x","files_changed":["a.go"]}`, + } cases := []struct { name string maxParallel int + primary string resolve func(string) string - wantRoles []string + wantPrimary int + wantStrict int }{ - {"two slots below capacity", 2, nil, []string{"acceptance", plan.RoleReviewer}}, - {"strict joins at capacity", 3, nil, - []string{"acceptance", plan.RoleReviewer, roleReviewerStrict}}, - {"unregistered override is dropped, not dispatched", 4, + {"primary answers: exactly one reviewer request, no strict", 4, + `{"approved":false,"score":40,"summary":"needs a test"}`, nil, 1, 0}, + {"primary answers below the old capacity threshold too", 2, + `{"approved":false,"score":40,"summary":"needs a test"}`, nil, 1, 0}, + {"empty primary: strict asked once, sequentially", 4, "", nil, 1, 1}, + {"unregistered strict override is dropped, not dispatched", 4, "", func(id string) string { if id == roleReviewerStrict { return "no-such-agent" } return id - }, - []string{"acceptance", plan.RoleReviewer}}, + }, 1, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + exec := &scriptedExec{answer: func(req ggagent.SubAgentRequest, _ int) string { + if req.AgentID == plan.RoleReviewer { + return tc.primary + } + return `{"approved":true,"score":88,"summary":"strict ok"}` + }} + r := NewRunner(exec, ggagent.NewSharedState()) r.MaxParallel = tc.maxParallel r.ResolveRole = tc.resolve - slots := r.reviewSlots(plan.Task{ID: "T1"}, gateState{}, nil, "prompt", plan.RoleReviewer) - var roles []string - for _, s := range slots { - roles = append(roles, s.Role) + r.Timeout = time.Minute + r.Log = func(string, ...interface{}) {} + if _, _, err := r.llmReview(context.Background(), task, gateState{}); err != nil { + t.Fatalf("llmReview: %v", err) } - if strings.Join(roles, ",") != strings.Join(tc.wantRoles, ",") { - t.Fatalf("slot roles = %v, want %v", roles, tc.wantRoles) + if got := exec.countFor(plan.RoleReviewer); got != tc.wantPrimary { + t.Fatalf("reviewer requests = %d, want %d", got, tc.wantPrimary) + } + if got := exec.countFor(roleReviewerStrict); got != tc.wantStrict { + t.Fatalf("reviewer-strict requests = %d, want %d", got, tc.wantStrict) + } + if got := exec.countFor("no-such-agent"); got != 0 { + t.Fatalf("an unregistered role was dispatched %d time(s)", got) + } + for _, p := range exec.promptsFor(roleReviewerStrict) { + if !strings.Contains(p, "STRICT:") { + t.Fatalf("strict reviewer was not told to be strict:\n%s", p) + } } }) } diff --git a/pkg/orchestrator/compose_teams.go b/pkg/orchestrator/compose_teams.go index 780c4b1..8698e0c 100644 --- a/pkg/orchestrator/compose_teams.go +++ b/pkg/orchestrator/compose_teams.go @@ -119,8 +119,34 @@ func (o *Orchestrator) decideTeams(query string, inventory []string, pins []stri } } +// teamPick is one run's library team selection, computed once. +// +// decideTeams (the composer) and teamsFromLibrary (the charter phase) both +// used to call preselectTeams, and each call listed up to 2000 workspace +// files and reloaded the block library to reach the same deterministic +// answer. The selection is a pure function of the query, the tree and the +// pins, none of which change between those two phases, so it is memoized +// for the run and cleared with the rest of the team state. +type teamPick struct { + query string + sel teams.Selection + roster []teams.Team +} + // preselectTeamsWith is preselectTeams with the run-level pin overridden. +// +// Only the run path (pins == nil) is memoized: a preview asks about pins the +// run does not have, and must not be answered from the run's cache nor +// pollute it. func (o *Orchestrator) preselectTeamsWith(query string, inventory []string, pins []string) (teams.Selection, []teams.Team) { + if pins == nil { + o.mu.Lock() + cached := o.teamPick + o.mu.Unlock() + if cached != nil && cached.query == query { + return cached.sel, cached.roster + } + } roster := o.teamRoster() if len(roster) == 0 { return teams.Selection{}, nil @@ -132,6 +158,11 @@ func (o *Orchestrator) preselectTeamsWith(query string, inventory []string, pins sel := teams.Select(roster, teams.Signals{Query: query, Files: files}, teams.Options{ Pinned: o.pinnedTeamsWith(pins), }) + if pins == nil { + o.mu.Lock() + o.teamPick = &teamPick{query: query, sel: sel, roster: roster} + o.mu.Unlock() + } return sel, roster } diff --git a/pkg/orchestrator/context_wiring.go b/pkg/orchestrator/context_wiring.go index dfc7981..1c6dcb6 100644 --- a/pkg/orchestrator/context_wiring.go +++ b/pkg/orchestrator/context_wiring.go @@ -177,14 +177,19 @@ func (o *Orchestrator) noteChangedFiles(paths ...string) { o.mu.Lock() defer o.mu.Unlock() if o.changedFiles == nil { - o.changedFiles = map[string]bool{} + o.changedFiles = map[string]int{} } for _, p := range paths { rel := strings.TrimSpace(p) if rel == "" { continue } - o.changedFiles[rel] = true + // Every write advances the sequence and the path's own count, a + // rewrite of an already-changed file included: the path SET is what + // the formatter needs, the counts are what "has this moved since" + // needs — for the whole tree (writeSeq) and for one team's lane. + o.writeSeq++ + o.changedFiles[rel]++ } } @@ -209,6 +214,53 @@ func (o *Orchestrator) resetChangedFiles() { return } o.mu.Lock() - o.changedFiles = map[string]bool{} + o.changedFiles = map[string]int{} + o.writeSeq = 0 + o.mu.Unlock() +} + +// changedFileWrites returns, for each changed path, how many times the run +// has written it. Sorted by path. +func (o *Orchestrator) changedFileWrites() []fileWrites { + if o == nil { + return nil + } + o.mu.Lock() + defer o.mu.Unlock() + out := make([]fileWrites, 0, len(o.changedFiles)) + for p, n := range o.changedFiles { + out = append(out, fileWrites{Path: p, Writes: n}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// fileWrites is one changed path and its write count. +type fileWrites struct { + Path string + Writes int +} + +// writeSequence is how many writes the run has recorded so far. +func (o *Orchestrator) writeSequence() int { + if o == nil { + return 0 + } + o.mu.Lock() + defer o.mu.Unlock() + return o.writeSeq +} + +// noteTreeMutation records that something OTHER than an agent write changed +// the tree or its ability to run — the formatter rewriting changed files, a +// dependency install — so a command result taken before it is not reused +// after it. It adds no path to the changed set: those passes touch nothing +// the run should be asked to format again. +func (o *Orchestrator) noteTreeMutation() { + if o == nil { + return + } + o.mu.Lock() + o.writeSeq++ o.mu.Unlock() } diff --git a/pkg/orchestrator/earlygate_test.go b/pkg/orchestrator/earlygate_test.go new file mode 100644 index 0000000..0247f01 --- /dev/null +++ b/pkg/orchestrator/earlygate_test.go @@ -0,0 +1,203 @@ +package orchestrator + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/loop" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/quality" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" +) + +// laneExec is a worker that writes each task's file and reports the write the +// way the tool layer would, so the team's lane fingerprint moves. +type laneExec struct { + root string + files map[string]string // task id → the file it writes + onWrite func(path string) +} + +func (e *laneExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + out := make([]ggagent.SubAgentResult, 0, len(reqs)) + for _, req := range reqs { + if strings.Contains(req.AgentID, "review") { + out = append(out, ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: `{"approved":true,"score":90,"summary":"ok","issues":[]}`}) + continue + } + rel, ok := e.files[req.TaskID] + if !ok { + // A correction ticket: it works the backend's file again. + rel = "cmd/main.go" + } + full := filepath.Join(e.root, filepath.FromSlash(rel)) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + _ = os.WriteFile(full, []byte("package main\n// "+req.TaskID+"\n"), 0o600) + if e.onWrite != nil { + e.onWrite(rel) + } + out = append(out, ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: fmt.Sprintf("Observation: ws_edit edited %s (1 replacement(s))\n", rel) + + fmt.Sprintf(`{"status":"done","summary":"done","files_changed":[%q]}`, rel)}) + } + return out, nil +} + +// A two-team board. The backend finishes its lane in wave 1 with a failing +// acceptance command. Its correction ticket must exist BEFORE wave 2 is +// announced, so the fix rides the next wave rather than waiting for the finish +// path — where it used to be raised only after the frontend's last task. +func TestARedHalfIsTicketedBeforeTheNextWave(t *testing.T) { + var mu sync.Mutex + var timeline []string + note := func(s string) { + mu.Lock() + timeline = append(timeline, s) + mu.Unlock() + } + o, _ := gateOrchestrator(t, func(cmd string) quality.SmokeResult { + if strings.HasPrefix(cmd, "go test") { + return quality.SmokeResult{Ran: true, OK: false, Command: cmd, + Summary: "FAIL: TestMain", Output: "--- FAIL: TestMain\ncmd/main.go:3: boom\nFAIL"} + } + return quality.SmokeResult{Ran: true, OK: true, Command: cmd, Summary: "ok"} + }) + o.onEvent = func(e Event) { note("event: " + e.Message) } + exec := &laneExec{root: o.cfg.Root, onWrite: func(p string) { o.noteChangedFiles(p) }, + files: map[string]string{"T1": "cmd/main.go", "T2": "web/App.tsx"}} + + r := loop.NewRunner(exec, ggagent.NewSharedState()) + r.Root = o.cfg.Root + r.MaxParallel = 1 // one task per wave: backend first, frontend after + r.MaxRetries = 0 + r.MaxWaves = 3 + r.IdleWait = time.Millisecond + r.Timeout = 5 * time.Second + r.PostWorkerSmoke = false + r.RequireSmoke = false + r.StaticQuality = false + r.ClaimsGate = false + r.WorkerCritique = false + r.ReviewParallel = false + r.Squads = o.squadPlanNow() + r.Log = func(format string, args ...interface{}) { + if msg := fmt.Sprintf(format, args...); strings.HasPrefix(msg, "wave:") { + note("log: " + msg) + } + } + // Exactly what buildRunner installs. + r.AfterWave = func(ctx context.Context, board *plan.Board, _ []plan.Task) { o.earlyTeamGate(ctx, board) } + + board := &plan.Board{QueryID: "run-teams", Query: "build both halves", Tasks: []plan.Task{ + {ID: "T1", Title: "backend handler", Squad: "backend", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "write cmd/main.go", Acceptance: "compiles", Files: []string{"cmd/main.go"}}, + {ID: "T2", Title: "frontend page", Squad: "frontend", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "write web/App.tsx", Acceptance: "renders", Files: []string{"web/App.tsx"}}, + }} + if err := r.RunBoard(context.Background(), board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + + mu.Lock() + tl := append([]string(nil), timeline...) + mu.Unlock() + ticketAt, wave2At, waves := -1, -1, 0 + for i, s := range tl { + if strings.HasPrefix(s, "log: wave:") { + waves++ + if waves == 2 && wave2At < 0 { + wave2At = i + } + } + if ticketAt < 0 && strings.Contains(s, "raised a correction ticket for team backend") { + ticketAt = i + } + } + if ticketAt < 0 { + t.Fatalf("no correction ticket was raised for the red backend half:\n%s", strings.Join(tl, "\n")) + } + if wave2At < 0 { + t.Fatalf("the board never announced a second wave:\n%s", strings.Join(tl, "\n")) + } + if ticketAt > wave2At { + t.Fatalf("the backend ticket (line %d) was raised AFTER wave 2 was announced (line %d):\n%s", + ticketAt, wave2At, strings.Join(tl, "\n")) + } + + var ticket *plan.Task + for i := range board.Tasks { + if board.Tasks[i].Squad == "backend" && board.Tasks[i].ID != "T1" { + ticket = &board.Tasks[i] + } + } + if ticket == nil { + t.Fatalf("the ticket is not on the board: %+v", board.Tasks) + } + // The gate is on the record, red, for the UI. + var backend TeamGate + for _, g := range o.TeamGates() { + if g.Team == "backend" { + backend = g + } + } + if !backend.Ran || backend.OK { + t.Fatalf("backend gate = %+v, want proved RED", backend) + } +} + +// The between-wave gate proves a lane once per state of that lane: not before +// the team wrote anything in its territory, not again while nothing in it has +// moved, and not for a team whose lane is still open. +func TestEarlyTeamGateProvesEachLaneStateOnce(t *testing.T) { + var ran []string + o, _ := gateOrchestrator(t, func(cmd string) quality.SmokeResult { + ran = append(ran, cmd) + return quality.SmokeResult{Ran: true, OK: true, Command: cmd, Summary: "ok"} + }) + ctx := context.Background() + board := &plan.Board{Tasks: []plan.Task{ + {ID: "T1", Squad: "backend", Column: plan.ColDone, Status: "done", Files: []string{"cmd/main.go"}}, + {ID: "T2", Squad: "frontend", Column: plan.ColReadyToDev, Files: []string{"web/App.tsx"}}, + }} + + // Complete, but nothing written in its territory yet: nothing to prove. + o.earlyTeamGate(ctx, board) + if len(ran) != 0 { + t.Fatalf("proved a lane the run never wrote to: %v", ran) + } + + o.noteChangedFiles("cmd/main.go") + o.earlyTeamGate(ctx, board) + o.earlyTeamGate(ctx, board) + if len(ran) != 1 || ran[0] != "go test ./..." { + t.Fatalf("backend proved %d time(s) on one lane state (%v), want once", len(ran), ran) + } + + // The frontend's lane is still open: never proved early. + o.noteChangedFiles("web/App.tsx") + o.earlyTeamGate(ctx, board) + if len(ran) != 1 { + t.Fatalf("a team with open work was proved: %v", ran) + } + + // The backend lane moved: proved again — through the memo, so the + // finish-path gate on the same tree costs no further run. + o.noteChangedFiles("cmd/other.go") + o.earlyTeamGate(ctx, board) + if len(ran) != 2 { + t.Fatalf("a moved lane was not re-proved: %v", ran) + } + board.Tasks[1].Column, board.Tasks[1].Status = plan.ColDone, "done" + o.runTeamAcceptance(ctx, board) + if len(ran) != 3 { // backend from the memo, frontend for the first time + t.Fatalf("finish path ran %d command(s) in total, want 3: %v", len(ran), ran) + } +} diff --git a/pkg/orchestrator/gate.go b/pkg/orchestrator/gate.go new file mode 100644 index 0000000..62af837 --- /dev/null +++ b/pkg/orchestrator/gate.go @@ -0,0 +1,178 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/UnicoLab/slmcode/pkg/loop" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" +) + +// ── One run-wide concurrency limit ─────────────────────────────────────── +// +// max_parallel is the measured concurrency knee of the model endpoint +// (calibration, docs/calibration.md), and for the fastest local models it is +// often 1: a second in-flight request does not run alongside the first, it +// queues behind it and inflates BOTH latencies. The knee used to be enforced in +// exactly one place — the execute wave — while everything else fanned out on +// its own: context ran beside explore, architect beside clarify, the explore +// phase's speculative digs added slots, review races added slots, and a wave's +// reviews multiplied them. On a max_parallel=1 endpoint that is not a +// throughput loss, it is a latency loss on every request the harness makes, +// and the measured role timeouts were then blown by queueing the harness +// itself created. +// +// The gate is a weighted semaphore of max_parallel slots, acquired in the ONE +// adapter every model request passes through: runRoleTracked (phase roles), +// the executor handed to loop.NewRunner (workers, reviewers, correctors, +// critique, triage, speculative reviews) and the multipass runner (planner +// and splitter). Phase pairs that used to be unconditionally parallel also +// run sequentially when max_parallel is 1 — see runPhases. + +// runGate is the run-wide semaphore. A nil gate admits everything. +type runGate struct { + slots chan struct{} + size int +} + +// newRunGate sizes the gate. n < 1 is normalized to 1: a zero-valued limit +// must mean "one at a time", never "nothing may run". +func newRunGate(n int) *runGate { + if n < 1 { + n = 1 + } + return &runGate{slots: make(chan struct{}, n), size: n} +} + +// acquire takes weight slots, clamped to [1, size] so a batch larger than the +// gate cannot deadlock on itself, and returns the matching release. It +// returns ctx.Err() when the run is canceled while waiting, in which case +// nothing is held and release is a no-op. +func (g *runGate) acquire(ctx context.Context, weight int) (release func(), err error) { + if g == nil { + return func() {}, nil + } + if weight < 1 { + weight = 1 + } + if weight > g.size { + weight = g.size + } + held := 0 + release = func() { + for ; held > 0; held-- { + <-g.slots + } + } + for held < weight { + if ctx == nil { + g.slots <- struct{}{} + held++ + continue + } + select { + case g.slots <- struct{}{}: + held++ + case <-ctx.Done(): + release() + return func() {}, ctx.Err() + } + } + return release, nil +} + +// runGateFor returns this run's gate, building it from max_parallel on first +// use. Run and Resume rebuild it at their start so a config patch between runs +// (Studio, stacks) takes effect; tests that call role helpers directly get a +// lazily built one. +func (o *Orchestrator) runGateFor() *runGate { + if o == nil { + return nil + } + o.mu.Lock() + defer o.mu.Unlock() + if o.gate == nil { + o.gate = newRunGate(o.maxParallelLocked()) + } + return o.gate +} + +// resetRunGate rebuilds the gate for a new run. o.mu must NOT be held. +func (o *Orchestrator) resetRunGate() { + if o == nil { + return + } + o.mu.Lock() + o.gate = newRunGate(o.maxParallelLocked()) + o.mu.Unlock() +} + +// maxParallelLocked is the configured knee, floored at 1. o.mu held or not +// held both work: it reads config only. +func (o *Orchestrator) maxParallelLocked() int { + if o == nil || o.cfg == nil || o.cfg.MaxParallel < 1 { + return 1 + } + return o.cfg.MaxParallel +} + +// gatedExecutor is the adapter every loop-side model request passes through: +// it holds one gate slot per request for the duration of the call and folds +// the real number of LLM round-trips each result represents into the run's +// llm_requests tally. +type gatedExecutor struct { + inner loop.SubAgentRunner + gate *runGate + // onRequests receives the number of LLM round-trips one result stood for. + onRequests func(n int) +} + +// ExecuteSubAgents implements loop.SubAgentRunner. +func (e *gatedExecutor) ExecuteSubAgents(ctx context.Context, reqs []ggagent.SubAgentRequest, + shared *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + release, err := e.gate.acquire(ctx, len(reqs)) + if err != nil { + return nil, err + } + defer release() + res, err := e.inner.ExecuteSubAgents(ctx, reqs, shared) + if e.onRequests != nil { + for _, r := range res { + e.onRequests(llmRequestsIn(r)) + } + } + return res, err +} + +// gatedExecutor returns the run-gated view of the orchestrator's executor, or +// a nil interface when there is no executor at all (the loop treats a nil +// Executor as "nothing can be dispatched", and a non-nil wrapper around nil +// would turn that into a panic). +func (o *Orchestrator) gatedExecutor() loop.SubAgentRunner { + if o == nil || o.executor == nil { + return nil + } + return &gatedExecutor{inner: o.executor, gate: o.runGateFor(), onRequests: o.bumpLLMCalls} +} + +// llmRequestsIn counts the LLM round-trips one sub-agent result cost: every +// assistant turn of its ReAct transcript is one request to the model. A result +// with no transcript (a fake, a provider that returns none) counts as one, so +// the tally can only ever be corrected upward from the old one-per-result. +// +// This is the number the operator waited on. One worker result used to be +// counted as ONE call while its transcript held eight tool turns, so a run +// that made 200 requests reported 40 and the queueing math in the calibration +// report was off by the same factor. +func llmRequestsIn(res ggagent.SubAgentResult) int { + n := 0 + for _, m := range res.Messages { + if strings.EqualFold(strings.TrimSpace(m.Role), "assistant") { + n++ + } + } + if n < 1 { + return 1 + } + return n +} diff --git a/pkg/orchestrator/gate_test.go b/pkg/orchestrator/gate_test.go new file mode 100644 index 0000000..a1dc637 --- /dev/null +++ b/pkg/orchestrator/gate_test.go @@ -0,0 +1,296 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/config" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" + "github.com/piotrlaczkowski/GoLangGraph/pkg/llm" +) + +func llmMessage(role string) llm.Message { return llm.Message{Role: role, Content: "x"} } + +// inflightExec is a fake model endpoint that measures the one thing the run +// gate exists to bound: how many requests are in flight AT ONCE. It answers +// every role the pipeline drives so a whole run can pass through it, and the +// worker really writes its file so the loop's evidence gates let the task +// finish. +type inflightExec struct { + root string + mu sync.Mutex + now int + max int + seen int +} + +func (e *inflightExec) ExecuteSubAgents(ctx context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + e.now += len(reqs) + e.seen += len(reqs) + if e.now > e.max { + e.max = e.now + } + e.mu.Unlock() + defer func() { + e.mu.Lock() + e.now -= len(reqs) + e.mu.Unlock() + }() + // Long enough that two concurrent requests overlap; short enough that a + // whole run is still fast. + select { + case <-time.After(3 * time.Millisecond): + case <-ctx.Done(): + } + out := make([]ggagent.SubAgentResult, 0, len(reqs)) + for _, req := range reqs { + out = append(out, ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, + Output: e.answer(req)}) + } + return out, nil +} + +func (e *inflightExec) stats() (max, seen int) { + e.mu.Lock() + defer e.mu.Unlock() + return e.max, e.seen +} + +func (e *inflightExec) answer(req ggagent.SubAgentRequest) string { + role := strings.ToLower(req.AgentID) + switch { + case strings.Contains(role, "explorer"): + return `{"summary":"tiny go module","relevant_files":["go.mod"],"key_symbols":[],"risks":[],"notes":""}` + case strings.Contains(role, "planner"): + return `{"summary":"two files","steps":["Create hello.go with func Hello","Create world.go with func World"],` + + `"goals":[],"assumptions":[],"risks":[]}` + case strings.Contains(role, "splitter"): + return `{"tasks":[` + + `{"id":"T1","title":"create hello.go","description":"Create hello.go with func Hello() string.",` + + `"role":"worker","files":["hello.go"],"acceptance":"hello.go exists","depends_on":[]},` + + `{"id":"T2","title":"create world.go","description":"Create world.go with func World() string.",` + + `"role":"worker","files":["world.go"],"acceptance":"world.go exists","depends_on":[]}]}` + case strings.Contains(role, "reviewer"): + return `{"approved":true,"score":92,"summary":"file present","issues":[]}` + case strings.Contains(role, "tester"): + return "Observation: ws_shell `ls` exit status 0\n" + + `{"passed":true,"commands":["ls"],"summary":"files present","failures":[]}` + case strings.Contains(role, "architect"): + return `{"approach":"two files","components":["hello.go"],"interfaces":[],"risks":[],"non_goals":[]}` + case strings.Contains(role, "worker"), strings.Contains(role, "corrector"), + strings.Contains(role, "deep"), strings.Contains(role, "editor"): + name := "hello.go" + fn := "Hello" + if strings.EqualFold(req.TaskID, "T2") { + name, fn = "world.go", "World" + } + if e.root != "" { + _ = os.WriteFile(filepath.Join(e.root, name), + []byte("package main\n\nfunc "+fn+"() string { return \"hi\" }\n"), 0o600) + } + return "Observation: ws_write wrote " + name + "\n" + + `{"status":"done","summary":"created ` + name + `","files_changed":["` + name + `"],"notes":""}` + } + return "- The project is a tiny Go module.\n" +} + +// The gate itself: N slots, never more than N holders, cancellation releases. +func TestRunGateBoundsInFlightHolders(t *testing.T) { + g := newRunGate(2) + var mu sync.Mutex + now, max := 0, 0 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + release, err := g.acquire(context.Background(), 1) + if err != nil { + t.Error(err) + return + } + defer release() + mu.Lock() + now++ + if now > max { + max = now + } + mu.Unlock() + time.Sleep(2 * time.Millisecond) + mu.Lock() + now-- + mu.Unlock() + }() + } + wg.Wait() + if max > 2 { + t.Fatalf("%d holders at once through a 2-slot gate", max) + } + + // A batch wider than the gate is clamped, not deadlocked. + release, err := g.acquire(context.Background(), 5) + if err != nil { + t.Fatal(err) + } + release() + + // Waiting on a full gate honors cancellation and holds nothing after. + hold, _ := g.acquire(context.Background(), 2) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + if _, err := g.acquire(ctx, 1); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("acquire on a full gate returned %v, want the ctx deadline", err) + } + hold() + release, err = g.acquire(context.Background(), 2) + if err != nil { + t.Fatalf("gate did not recover its slots after a canceled wait: %v", err) + } + release() + + // A nil gate admits everything. + var none *runGate + if _, err := none.acquire(context.Background(), 3); err != nil { + t.Fatal(err) + } +} + +// The phase pairs used to spawn one goroutine per phase unconditionally. At +// max_parallel=1 they run one after another, in order. +func TestPhasePairsRunSequentiallyAtMaxParallelOne(t *testing.T) { + var mu sync.Mutex + now, max := 0, 0 + var order []string + phase := func(name string) func() phaseResult { + return func() phaseResult { + mu.Lock() + now++ + if now > max { + max = now + } + order = append(order, name) + mu.Unlock() + time.Sleep(2 * time.Millisecond) + mu.Lock() + now-- + mu.Unlock() + return phaseResult{name: name} + } + } + + o := testOrch(t, func(c *config.Config) { c.SetMaxParallel(1) }) + res := o.runPhases(context.Background(), phase("context"), phase("explore")) + if len(res) != 2 || res["context"].err != nil || res["explore"].err != nil { + t.Fatalf("results = %+v", res) + } + if max != 1 { + t.Fatalf("%d phases in flight at once with max_parallel=1", max) + } + if strings.Join(order, ",") != "context,explore" { + t.Fatalf("order = %v, want the phases in the order given", order) + } + + // Canceled before the second phase: reported under a stable key. + ctx, cancel := context.WithCancel(context.Background()) + res = o.runPhases(ctx, func() phaseResult { cancel(); return phaseResult{name: "first"} }, phase("second")) + if r, ok := res["canceled-1"]; !ok || r.err == nil { + t.Fatalf("a phase that never started must be reported as canceled: %+v", res) + } +} + +// The headline: a WHOLE run — context, explore with its speculative digs, +// architect, plan, split, the execute waves with their reviews, the tester, +// memory — never has more requests in flight than max_parallel. Measured at +// the executor, which is where the endpoint would see them. +func TestFullRunNeverExceedsMaxParallel(t *testing.T) { + for _, par := range []int{1, 2} { + par := par + t.Run(map[int]string{1: "max_parallel=1", 2: "max_parallel=2"}[par], func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module demo\n\ngo 1.22\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg := config.Default(root) + // InitWorkspace applies the Go language pack, which turns the QA + // gate and the per-task smoke ON; the knobs below are set after + // it so this run never shells out to a real toolchain. + if err := InitWorkspace(root, cfg); err != nil { + t.Fatal(err) + } + cfg.StructuredDecoding = "off" + cfg.DynamicPipeline = false + cfg.Squads = false + cfg.ClarifyMode = "off" + cfg.PlanApprove = "auto" + cfg.ContinueAsk = "off" + cfg.EscalateAsk = "off" + cfg.QAGate = false + cfg.PostWorkerSmoke = false + cfg.RequireSmoke = false + cfg.ScopeJudge = false + cfg.PlaceholderPass = false + // think_passes=2 turns ON the paths that used to add slots: the + // speculative explore digs, the plan critique, the per-wave + // coordinator and distillation. + cfg.ThinkPasses = 2 + cfg.MaxRetries = 1 + cfg.TaskTimeout = 30 * time.Second + cfg.SetMaxParallel(par) + cfg.Normalize() + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + exec := &inflightExec{root: root} + o.executor = exec + // The multipass runner talks to the provider directly; with no + // provider in this fixture the planner and splitter take the + // single-shot path, which routes them through exec like every + // other role. The multipass path holds a gate slot of its own. + o.think = nil + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + res, err := o.Run(ctx, "Create hello.go and world.go with one function each") + if err != nil { + t.Fatalf("run: %v", err) + } + max, seen := exec.stats() + t.Logf("%d requests, max %d in flight, success=%v: %s", seen, max, res.Success, res.Summary) + if seen < 6 { + t.Fatalf("only %d requests reached the executor — this did not exercise a full run", seen) + } + if max > par { + t.Fatalf("%d requests in flight at once with max_parallel=%d", max, par) + } + }) + } +} + +// llm_requests counts round-trips, not results: a worker whose ReAct loop made +// eight model calls is eight requests. +func TestLLMRequestsCountAssistantTurns(t *testing.T) { + if n := llmRequestsIn(ggagent.SubAgentResult{}); n != 1 { + t.Fatalf("a result with no transcript counts as %d, want 1", n) + } + res := ggagent.SubAgentResult{} + for _, role := range []string{"system", "user", "assistant", "tool", "assistant", "assistant"} { + res.Messages = append(res.Messages, llmMessage(role)) + } + if n := llmRequestsIn(res); n != 3 { + t.Fatalf("counted %d requests over three assistant turns", n) + } +} diff --git a/pkg/orchestrator/gaterunway_test.go b/pkg/orchestrator/gaterunway_test.go index e8b2319..ebe422f 100644 --- a/pkg/orchestrator/gaterunway_test.go +++ b/pkg/orchestrator/gaterunway_test.go @@ -112,7 +112,7 @@ func TestGateSaysWhyItStoppedEarly(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) defer cancel() - _ = o.runQAGate(ctx, "implement the thing", midBoard()) + _ = o.runQAGate(ctx, "implement the thing", midBoard(), preTest{}) joined := strings.Join(events, "\n") if !strings.Contains(joined, "not enough time left") { diff --git a/pkg/orchestrator/looptimeout_test.go b/pkg/orchestrator/looptimeout_test.go new file mode 100644 index 0000000..860c3fa --- /dev/null +++ b/pkg/orchestrator/looptimeout_test.go @@ -0,0 +1,126 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/plan" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" +) + +// budgetExec records the timeout every request arrives with, writes the +// worker's file so the loop's evidence gates let the task finish, and takes a +// measurable moment per call so the latency it produces is a real sample. +type budgetExec struct { + root string + mu sync.Mutex + reqs []ggagent.SubAgentRequest +} + +func (e *budgetExec) ExecuteSubAgents(_ context.Context, reqs []ggagent.SubAgentRequest, + _ *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + e.mu.Lock() + e.reqs = append(e.reqs, reqs...) + e.mu.Unlock() + time.Sleep(2 * time.Millisecond) + out := make([]ggagent.SubAgentResult, 0, len(reqs)) + for _, req := range reqs { + reply := `{"approved":true,"score":90,"summary":"looks good","issues":[]}` + if !strings.Contains(req.AgentID, "review") { + name := strings.ToLower(req.TaskID) + ".go" + _ = os.WriteFile(filepath.Join(e.root, name), []byte("package main\n"), 0o600) + reply = "Observation: ws_edit edited " + name + " (1 replacement(s))\n" + + `{"status":"done","summary":"done","files_changed":["` + name + `"]}` + } + out = append(out, ggagent.SubAgentResult{AgentID: req.AgentID, TaskID: req.TaskID, Output: reply}) + } + return out, nil +} + +func (e *budgetExec) timeoutsFor(role string) []time.Duration { + e.mu.Lock() + defer e.mu.Unlock() + var out []time.Duration + for _, r := range e.reqs { + if r.AgentID == role { + out = append(out, r.Timeout) + } + } + return out +} + +// The execute loop's workers, reviewers and correctors own most of a run's +// wall clock, and they used to run on the flat task_timeout while contributing +// no latency samples at all. buildRunner now hands the loop the same measured +// policy the phase roles use, and the loop reports back. +func TestLoopRolesUseMeasuredBudgetsAndRecordLatency(t *testing.T) { + const ceiling = 12 * time.Minute + o := newTimeoutFixture(t, "qwen2.5-coder:14b", ceiling) + o.cfg.PostWorkerSmoke = false + o.cfg.RequireSmoke = false + o.cfg.StaticQuality = false + o.cfg.ClaimsGate = false + o.cfg.WorkerCritique = false + o.cfg.MaxRetries = 0 + o.cfg.SetMaxParallel(1) + o.boardStore = plan.NewLiveStore(o.cfg.SlmDir()) + o.shared = ggagent.NewSharedState() + exec := &budgetExec{root: o.cfg.Root} + o.executor = exec + o.buildPackers(nil, 32768) + + // Three reviewer observations at 20s: p95 × 1.5 = 30s, floored to the + // light-role floor of 60s. The worker has no samples and keeps the ceiling. + o.seedLatency(t, plan.RoleReviewer, 3, 20*time.Second) + + r := o.buildRunner("implement the thing", "run-budget", "") + r.IdleWait = time.Millisecond + r.Log = func(string, ...interface{}) {} + if r.RoleTimeout == nil || r.OnRoleLatency == nil { + t.Fatal("buildRunner did not wire RoleTimeout / OnRoleLatency") + } + ctx := context.Background() + if got := r.RoleTimeout(ctx, plan.RoleReviewer); got != roleFloorLight { + t.Fatalf("reviewer budget = %v, want the %v floor rather than the %v task_timeout", got, roleFloorLight, ceiling) + } + if got := r.RoleTimeout(ctx, plan.RoleWorker); got != ceiling { + t.Fatalf("cold worker budget = %v, want the full %v", got, ceiling) + } + + st := o.latencyStore() + if _, before := st.P95(plan.RoleWorker, o.modelFamily()); before != 0 { + t.Fatalf("worker already has %d samples before the wave", before) + } + + board := &plan.Board{QueryID: "run-budget", Query: "implement the thing", Tasks: []plan.Task{{ + ID: "T1", Title: "build t1", Role: plan.RoleWorker, Column: plan.ColReadyToDev, + Description: "write t1.go", Acceptance: "file written", Files: []string{"t1.go"}, + }}} + if err := o.boardStore.Replace(*board); err != nil { + t.Fatal(err) + } + if err := r.RunBoard(ctx, board); err != nil { + t.Fatalf("RunBoard: %v", err) + } + + if _, after := st.P95(plan.RoleWorker, o.modelFamily()); after < 1 { + t.Fatalf("the wave recorded %d worker samples — the loop is still not feeding latency memory", after) + } + // Every reviewer request the wave issued was dispatched on the measured + // budget, never on the task_timeout. + for _, to := range exec.timeoutsFor(plan.RoleReviewer) { + if to != roleFloorLight { + t.Fatalf("a reviewer request went out with timeout %v, want the measured %v", to, roleFloorLight) + } + } + for _, to := range exec.timeoutsFor(plan.RoleWorker) { + if to != ceiling { + t.Fatalf("a cold worker request went out with timeout %v, want the %v ceiling", to, ceiling) + } + } +} diff --git a/pkg/orchestrator/orchestrator.go b/pkg/orchestrator/orchestrator.go index 30d13e2..16cbe35 100644 --- a/pkg/orchestrator/orchestrator.go +++ b/pkg/orchestrator/orchestrator.go @@ -129,6 +129,13 @@ type Orchestrator struct { // never written into squads.json — that file is the org chart the next run // inherits, not a record of what this one proved. teamGates teamGates + // gate is the run-wide concurrency limit every model request passes + // through (see gate.go). Rebuilt per run from max_parallel; guarded by mu. + gate *runGate + // teamPick memoizes the library team selection for this run so the + // composer and the charter phase do not each rescan the workspace and + // reload the block library to reach the same answer. Guarded by mu. + teamPick *teamPick // workspace / repoMap / tracker come from the tool layer so the // orchestrator can reset the per-task loop guard and seed focus discovery. @@ -208,7 +215,15 @@ type Orchestrator struct { // changedFiles is the set of workspace paths this run has written, fed by // the tool layer's OnFileChange hook. The QA gate formats exactly these — // quality.FormatChangedFiles refuses to format anything else. - changedFiles map[string]bool + changedFiles map[string]int + // writeSeq counts every recorded write this run, so a fingerprint of the + // tree changes when an already-changed file is written again. Guarded by + // mu; see changedFingerprint. + writeSeq int + // smokeMemo remembers what each command answered on each tree state this + // run, so the objective/acceptance command is not re-run against a tree + // nothing has touched since. Guarded by mu; see runSmokeIn. + smokeMemo map[string]quality.SmokeResult // qaSmoke executes the QA/acceptance command. Nil in production, where // every call falls through to quality.RunSmoke; tests inject a fake so the @@ -377,6 +392,14 @@ func New(cfg *config.Config) (*Orchestrator, error) { } } factory.ModelProfiles = cfg.ModelProfiles + if cfg.ReactCompact { + // Live ReAct compaction: old tool results are elided deterministically + // on every request a tool-using role sends once its transcript passes + // the threshold. The window is resolved per role from the model profile. + factory.LiveElide = backends.LiveElide{ + AtPercent: cfg.ReactCompactAtPercent, KeepLast: compact.DefaultElideKeepLast, + } + } if prof := config.ResolveModelProfile(cfg.ModelProfiles, cfg.Model); prof.MaxTokens > 0 || prof.MaxTurns > 0 || prof.Temperature > 0 { factory.ProfileMaxTokens = prof.MaxTokens factory.ProfileMaxTurns = prof.MaxTurns @@ -654,6 +677,7 @@ func (o *Orchestrator) Run(ctx context.Context, query string) (*Result, error) { } o.resetChangedFiles() o.resetObjectiveProbes() + o.resetRunGate() // Learn whether the objective command already passes, concurrently with the // context/explore/plan phases that follow. This is the fact that decides // whether a green result at the end is evidence or a coincidence, and it @@ -1037,6 +1061,13 @@ func (o *Orchestrator) runSpecialist(ctx context.Context, runID, query string, s } func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack string, start time.Time) (*Result, error) { + // Team state is per run, and the orchestrator outlives a run in a + // long-lived Studio process: cleared before anything below can set it. + // This used to happen halfway down, after explore — and never on Resume — + // which is how one run's green team gates survived into the next run's + // verdict (see resetTeamState). + o.resetTeamState() + // 0 Auto-load AGENTS.md / CLAUDE.md / PROJECT instructions (Claude Code style). // // These used to be appended to SCRATCH.md — a write-only sink, written in 25 @@ -1083,7 +1114,7 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin // context is fast (≤400 words CONTEXT.md); explore does a codebase deep-dive. var exploreOut, archOut, docsOut string - parResults := runPhaseParallel(ctx, + parResults := o.runPhases(ctx, func() phaseResult { // --- 1 Context --- if err := o.runPipelineSlots(ctx, "context", "before", query, "", ""); err != nil { @@ -1212,12 +1243,6 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin return nil, r.err } - // Team state is per run, and the orchestrator outlives a run in a - // long-lived Studio process: cleared before anything below can set it. - o.mu.Lock() - o.squadPlan, o.singleTeam = nil, nil - o.mu.Unlock() - // 2a Dynamic pipeline composition (optional): the composer specialist assembles // a task-specific pipeline (phases, team, tools, skills) before design/plan. if o.cfg.DynamicPipeline { @@ -1230,7 +1255,12 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin // returns nil — meaning "one stream" — for every single-domain query and // every failure mode. if o.cfg.Squads { - o.squadPlan = o.assembleSquads(ctx, query, inventory, exploreOut, archOut) + // Under the lock: staffingPlan and the Studio poll read it there, and + // the explore phase's speculative digs may still be draining. + p := o.assembleSquads(ctx, query, inventory, exploreOut, archOut) + o.mu.Lock() + o.squadPlan = p + o.mu.Unlock() } // 2b+2c Architect + Clarify run in parallel after explore. @@ -1239,7 +1269,7 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin var clarify plan.ClarifyResult var prd plan.ScopePRD - archClarifyResults := runPhaseParallel(ctx, + archClarifyResults := o.runPhases(ctx, func() phaseResult { // --- 2b Architect (skip if already ran in speculative digs) --- archWhen := o.Pipeline().PhaseWhen("architect") @@ -1605,12 +1635,20 @@ func (o *Orchestrator) runRoleTracked(ctx context.Context, role, taskID, input s o.emit(role, fmt.Sprintf("fallback role %s → %s (agent not registered)", role, execRole), "") } o.emitFull(execRole, stream.KindAgentStart, execRole, taskID, "started", scopeFromInput(input), "") - o.bumpLLMCalls(1) start := time.Now() timeout := o.roleTimeoutWithin(ctx, execRole) rctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - results, err := o.executor.ExecuteSubAgents(rctx, []ggagent.SubAgentRequest{{ + // Through the run gate, not o.executor directly: this is the one place a + // phase role reaches the model, and the gate is what keeps a phase pair, a + // speculative dig and a review race from exceeding max_parallel together. + // The gate also counts the request toward llm_requests. + exec := o.gatedExecutor() + if exec == nil { + o.emitFullL(execRole, stream.KindAgentEnd, execRole, taskID, "no executor", "", "", stream.LevelError) + return "", fmt.Errorf("no executor configured for %s", execRole) + } + results, err := exec.ExecuteSubAgents(rctx, []ggagent.SubAgentRequest{{ AgentID: execRole, Input: input, Timeout: timeout, ShareState: true, }}, o.shared) elapsed := time.Since(start) @@ -1716,6 +1754,9 @@ func (o *Orchestrator) runRoleMultipassTracked(ctx context.Context, role, taskID } o.think.SetPassTimeout(pass).SetBudget(budget).SetFactory(o.factory.Create) o.think.OnCall = func(ci multipass.CallInfo) { + // Every pass is one real round-trip; the multipass runner bypasses the + // gated executor, so it is counted here. + o.bumpLLMCalls(1) // Same evidence rule as the single-shot path: the per-call budget here // IS roleTimeout(execRole), so these samples are directly comparable. o.recordRoleLatency(execRole, ci.Elapsed, ci.Err == nil || isTimeoutError(ci.Err)) @@ -1731,6 +1772,16 @@ func (o *Orchestrator) runRoleMultipassTracked(ctx context.Context, role, taskID scopeFromInput(input), "") rctx, cancel := context.WithTimeout(ctx, budget) defer cancel() + // The passes run one after another, so the whole cycle holds ONE slot of + // the run gate: a planner refining its draft is still one request in + // flight at the endpoint, and it must count against max_parallel like the + // single-shot roles it runs beside. + release, gerr := o.runGateFor().acquire(rctx, 1) + if gerr != nil { + o.emitFullL(execRole, stream.KindAgentEnd, execRole, taskID, "canceled before start", "", "", stream.LevelError) + return "", gerr + } + defer release() start := time.Now() // ExecuteRole caches and resets the agent instead of rebuilding it: a // rebuild re-resolves the provider, tools and profile caps every call. @@ -1900,6 +1951,52 @@ func (o *Orchestrator) applyCoordinatorActions(board *plan.Board, raw string) { } } +// waveEventful reports whether a finished wave gives the after-wave model +// passes (memory distillation, the coordinator) anything to act on: a task +// that failed, one that was escalated to a human, or a lesson worth a model's +// attention. A wave of plain green tasks with nothing learned is not +// eventful, and the reason is returned in words so the skip can be shown +// rather than silently taken. +func waveEventful(wave []plan.Task) (bool, string) { + if len(wave) == 0 { + return false, "the wave finished no tasks" + } + failed, escalated, lessons := 0, 0, 0 + for _, t := range wave { + t.Normalize() + switch { + case t.Column == plan.ColBlocked || t.Status == plan.StatusFailed || + (t.Error != "" && t.Column != plan.ColDone): + failed++ + case taskEscalated(t): + escalated++ + } + lessons += notableLessons(t) + } + if failed == 0 && escalated == 0 && lessons == 0 { + return false, fmt.Sprintf("all %d task(s) finished green with no failure, escalation or new lesson", len(wave)) + } + return true, fmt.Sprintf("%d failed, %d escalated, %d lesson(s)", failed, escalated, lessons) +} + +// notableLessons counts the lessons on a task that are worth a model pass: a +// failure lesson, or a human note the task honored. learning.Extract also +// emits a "success" lesson from every done task's output and a "convention" +// one from its acceptance text; those are routine — every green task has them +// — and counting them would make every wave "eventful" and the skip a no-op. +func notableLessons(t plan.Task) int { + n := 0 + for _, l := range learning.Extract(t) { + if l.Kind == "failure" { + n++ + } + } + if t.Column == plan.ColDone && strings.TrimSpace(plan.PromptNotes(t.Notes)) != "" { + n++ + } + return n +} + // evolveAfterWave updates CONTEXT + MEMORY from wave results and refreshes // pending task packs so later specialists see evolving project knowledge. func (o *Orchestrator) evolveAfterWave(ctx context.Context, query, skillPack string, board *plan.Board, wave []plan.Task) { @@ -1916,8 +2013,16 @@ func (o *Orchestrator) evolveAfterWave(ctx context.Context, query, skillPack str } md := learning.RenderMarkdown(lessons) - // Optional SLM distillation of the wave (cheap when think_passes>=2) - if o.cfg.ThinkPasses >= 2 && len(wave) > 0 { + // Optional SLM distillation of the wave (think_passes>=2) — and only of a + // wave that produced something to distill. A wave whose tasks all went + // green with no lesson extracted used to buy a memory round-trip that + // returned nothing new; on a local model that is a minute per wave for + // no information. + eventful, why := waveEventful(wave) + if o.cfg.ThinkPasses >= 2 && len(wave) > 0 && !eventful { + o.emit("learn", "wave distillation skipped — "+why, "") + } + if o.cfg.ThinkPasses >= 2 && len(wave) > 0 && eventful { var brief strings.Builder brief.WriteString("Wave results for learning:\n") for _, t := range wave { diff --git a/pkg/orchestrator/parallel.go b/pkg/orchestrator/parallel.go index 1b3838b..615b9bd 100644 --- a/pkg/orchestrator/parallel.go +++ b/pkg/orchestrator/parallel.go @@ -52,6 +52,40 @@ func runPhaseParallel(ctx context.Context, phases ...func() phaseResult) map[str return results } +// runPhasesSequential is runPhaseParallel one phase at a time, in the order +// given, with the same result map and the same cancellation folding. +func runPhasesSequential(ctx context.Context, phases ...func() phaseResult) map[string]phaseResult { + results := make(map[string]phaseResult, len(phases)) + for i, f := range phases { + var r phaseResult + if err := ctx.Err(); err != nil { + r = phaseResult{name: fmt.Sprintf("canceled-%d", i), err: err} + } else { + r = f() + if r.err == nil { + if err := ctx.Err(); err != nil { + r.err = err + } + } + } + results[r.name] = r + } + return results +} + +// runPhases runs a pair of independent phases as the endpoint can bear: +// concurrently when max_parallel allows a second in-flight request, one after +// another when it does not. Every model call inside a phase still passes +// through the run gate, so this is not what enforces the limit — it is what +// stops the harness from spawning goroutines whose only effect at +// max_parallel=1 would be to queue on it in a nondeterministic order. +func (o *Orchestrator) runPhases(ctx context.Context, phases ...func() phaseResult) map[string]phaseResult { + if o != nil && o.maxParallelLocked() <= 1 { + return runPhasesSequential(ctx, phases...) + } + return runPhaseParallel(ctx, phases...) +} + // canceledPhase returns the first cancellation error in a parallel result set — // but only when the RUN's context is actually gone. A phase that failed with an // error whose text mentions a cancellation, while the run itself is healthy, is diff --git a/pkg/orchestrator/plansplit.go b/pkg/orchestrator/plansplit.go index 7cd2e50..d4716cf 100644 --- a/pkg/orchestrator/plansplit.go +++ b/pkg/orchestrator/plansplit.go @@ -194,12 +194,32 @@ func (o *Orchestrator) runSplitPhase(ctx context.Context, in planSplitInput, pl return nil, err } splitAgent := o.phaseAgent("split", "splitter") + // Discovery first: it does not depend on the splitter, and whether the + // splitter is worth a model call depends on it. + discovered := plan.DiscoverRelevantFiles(o.cfg.Root, in.Query, in.ExploreOut) + if len(in.Discovered) > 0 { + discovered = plan.ReconcileFiles(o.cfg.Root, append(discovered, in.Discovered...), in.Inventory) + } + if rm := o.repoMapNow(); rm != nil { + // The ranked symbol index finds targets that filename matching misses. + discovered = plan.FilterExisting(o.cfg.Root, + mergeUnique(discovered, rm.RankFilesFor(in.Query, 8))) + } var tasksOut string + trivial := false if o.Pipeline().HasReplace("split") { if err := o.runPipelineSlots(ctx, "split", "replace", in.Query, in.ExploreOut, planOut); err != nil { return nil, err } tasksOut = `{"tasks":[]}` + } else if o.phaseEnabled("split") && trivialPlan(pl, discovered) { + // A one-step plan over one or two known files has nothing to split. + // The splitter is the slowest role after the planner (a multipass + // call), and on this input it returns the plan's one step back as one + // task — the board fallbackTasks builds deterministically. + trivial = true + o.emit("split", fmt.Sprintf("splitter skipped — a %d-step plan over %d known file(s) is one task; "+ + "building the board directly", len(pl.Steps), len(discovered)), "") } else if o.phaseEnabled("split") { o.emitAgent("split", splitAgent, "", "atomic task split", "", "") splitPrompt := o.buildSplitterPrompt(in.Query, splitAgent, planOut, in.PRD, in.Clarify, replanNotes) @@ -220,15 +240,18 @@ func (o *Orchestrator) runSplitPhase(ctx context.Context, in planSplitInput, pl tasks, err := plan.ParseTasksJSON(tasksOut) if err != nil || len(tasks) == 0 { tasks = fallbackTasks(pl) - } - discovered := plan.DiscoverRelevantFiles(o.cfg.Root, in.Query, in.ExploreOut) - if len(in.Discovered) > 0 { - discovered = plan.ReconcileFiles(o.cfg.Root, append(discovered, in.Discovered...), in.Inventory) - } - if rm := o.repoMapNow(); rm != nil { - // The ranked symbol index finds targets that filename matching misses. - discovered = plan.FilterExisting(o.cfg.Root, - mergeUnique(discovered, rm.RankFilesFor(in.Query, 8))) + if trivial { + // The known files ARE the scope, and a scoped task is ready: + // fallbackTasks marks its tasks pending (→ to_scope, human + // scoping) because it normally has no idea where they land. Here + // it does. Without this the splitter skip would park the one task + // it was meant to save. + for i := range tasks { + tasks[i].Files = append([]string(nil), discovered...) + tasks[i].Column = plan.ColReadyToDev + tasks[i].Status = plan.StatusReady + } + } } tasks = plan.SanitizeTasksIn(tasks, in.ExploreOut+"\n"+strings.Join(discovered, "\n"), in.Query, o.cfg.Root) tasks = plan.EnsureTaskPRDs(tasks, in.PRD, in.Query) @@ -289,6 +312,19 @@ func (o *Orchestrator) runSplitPhase(ctx context.Context, in planSplitInput, pl return board, nil } +// maxTrivialPlanFiles is the most discovered files a plan may involve and still +// be split deterministically: with one step and at most two files the split is +// the step itself, and the splitter can only return it or invent scope. +const maxTrivialPlanFiles = 2 + +// trivialPlan reports whether the plan needs no splitter: at most one step and +// between one and maxTrivialPlanFiles known target files. With NO known file +// the splitter is still asked — it may resolve a target discovery missed, and +// a task with no scope would only be parked for human scoping. +func trivialPlan(pl plan.Plan, discovered []string) bool { + return len(pl.Steps) <= 1 && len(discovered) >= 1 && len(discovered) <= maxTrivialPlanFiles +} + // blockUnscopedTask parks a task whose targets could not be resolved. func blockUnscopedTask(t *plan.Task) { if t == nil { diff --git a/pkg/orchestrator/qagate.go b/pkg/orchestrator/qagate.go index d544d9c..2eb381c 100644 --- a/pkg/orchestrator/qagate.go +++ b/pkg/orchestrator/qagate.go @@ -81,17 +81,103 @@ func (o *Orchestrator) runSmoke(ctx context.Context, cmd string) quality.SmokeRe // runSmokeIn is runSmoke with an explicit timeout (the build preflight uses a // much shorter one than a whole test suite). +// +// It is the ONE choke point every harness-run command passes through — the +// deterministic pre-test, each team's acceptance, the integration command, +// the QA gate's rounds, the objective probes, the bootstrap install — and so +// it is where the run's smoke memo lives. A command asked of a tree nothing +// has written to since it last answered gets that answer back instead of a +// second run. Measured live, the objective command ran up to FOUR times per +// finish on an unchanged tree (pre-test, team gate, QA round 1, final check); +// on a suite that takes minutes that was most of the finish path's wall clock +// spent re-proving one fact. func (o *Orchestrator) runSmokeIn(ctx context.Context, cmd string, timeout time.Duration) quality.SmokeResult { + sr, _ := o.runSmokeMemo(ctx, cmd, timeout) + return sr +} + +// runSmokeMemo is runSmokeIn reporting whether the answer came from the memo. +func (o *Orchestrator) runSmokeMemo(ctx context.Context, cmd string, timeout time.Duration) (quality.SmokeResult, bool) { root := "" if o != nil && o.cfg != nil { root = o.cfg.Root } + key, before := o.smokeMemoKey(cmd) + if sr, ok := o.memoizedSmoke(key); ok { + if o != nil { + o.emitFull("test", stream.KindDebug, "qa", "", + "reusing the last result of `"+truncate(cmd, 80)+"` — nothing has been written since it ran", "", "") + } + return sr, true + } + var sr quality.SmokeResult if o != nil && o.qaSmoke != nil { - return o.qaSmoke(ctx, root, cmd, timeout) + sr = o.qaSmoke(ctx, root, cmd, timeout) + } else { + sr = quality.RunSmoke(ctx, root, cmd, timeout) } - return quality.RunSmoke(ctx, root, cmd, timeout) + o.rememberSmoke(key, before, timeout, sr) + return sr, false } +// smokeMemoKey keys a command by what it was run against: the command text +// and the tree fingerprint at the moment it started. +func (o *Orchestrator) smokeMemoKey(cmd string) (key, fingerprint string) { + fingerprint = o.changedFingerprint() + return strings.TrimSpace(cmd) + "\x00" + fingerprint, fingerprint +} + +// memoizedSmoke returns the remembered answer for key, if any. +func (o *Orchestrator) memoizedSmoke(key string) (quality.SmokeResult, bool) { + if o == nil { + return quality.SmokeResult{}, false + } + o.mu.Lock() + defer o.mu.Unlock() + sr, ok := o.smokeMemo[key] + return sr, ok +} + +// rememberSmoke stores a result worth reusing: one that actually ran, did not +// hit its timeout (a killed command answers "it took at least this long", not +// "it fails"), and whose tree did not move WHILE it ran — the baseline probe +// runs alongside the first waves, and a result taken across a write describes +// no tree the run will ever see again. +func (o *Orchestrator) rememberSmoke(key, before string, timeout time.Duration, sr quality.SmokeResult) { + if o == nil || !sr.Ran { + return + } + if timeout > 0 && sr.Duration >= timeout { + return + } + if o.changedFingerprint() != before { + return + } + o.mu.Lock() + if o.smokeMemo == nil { + o.smokeMemo = map[string]quality.SmokeResult{} + } + o.smokeMemo[key] = sr + o.mu.Unlock() +} + +// hasMemoizedSmoke reports whether key has a remembered answer. +func (o *Orchestrator) hasMemoizedSmoke(key string) bool { + _, ok := o.memoizedSmoke(key) + return ok +} + +// rememberSmokeNow stores sr against the tree AS IT IS NOW — for a command +// that itself changed the tree (a dependency install), whose answer belongs +// to the state it produced rather than the one it started from. +func (o *Orchestrator) rememberSmokeNow(cmd string, sr quality.SmokeResult) { + key, fp := o.smokeMemoKey(cmd) + o.rememberSmoke(key, fp, 0, sr) +} + +// resetSmokeMemo forgets every remembered command result. o.mu must be held. +func (o *Orchestrator) resetSmokeMemoLocked() { o.smokeMemo = nil } + // objectiveGate is ONE evaluation of the project's objective: the same command // runQAGate iterates, run once, classified the same way. // @@ -314,6 +400,8 @@ func (o *Orchestrator) resetObjectiveProbes() { // an empty changed-file set, so even the fingerprint guard would agree they // describe "the same" tree. o.objectiveShell = nil + // Same argument for remembered command results. + o.resetSmokeMemoLocked() } // objectiveProbesSpent reports how many probes this run has used. @@ -734,7 +822,13 @@ func (o *Orchestrator) objectiveMetBetweenWaves(ctx context.Context, board *plan // runQAGate iterates a project test/smoke command until green or max rounds. // On failure it asks the tester/corrector specialists to fix, then re-runs. // Returns true when the gate ends red (caller should rewrite plan/tasks). -func (o *Orchestrator) runQAGate(ctx context.Context, query string, board *plan.Board) bool { +// +// pre is the deterministic pre-test's run of the same command, handed over so +// round 1 reuses it when the tree has not moved since (its Fingerprint says +// what it was taken against). The smoke memo would answer the same way; the +// explicit hand-off makes the reuse visible in the gate's own events rather +// than only in a debug line. +func (o *Orchestrator) runQAGate(ctx context.Context, query string, board *plan.Board, pre preTest) bool { if o == nil || o.cfg == nil || !o.cfg.QAGate { return false } @@ -778,7 +872,13 @@ func (o *Orchestrator) runQAGate(ctx context.Context, query string, board *plan. o.emitFull("test", stream.KindAgentStart, "qa", "", fmt.Sprintf("qa_gate %d/%d: %s", round, max, cmd), "", "") - sr := o.runSmoke(ctx, cmd) + var sr quality.SmokeResult + if round == 1 && pre.reusableFor(cmd, o.changedFingerprint()) { + o.emit("test", "qa_gate: reusing the pre-test's run of "+cmd+" — nothing has been written since", "") + sr = *pre.Smoke + } else { + sr = o.runSmoke(ctx, cmd) + } // classifySmoke, not a second opinion: the finish path and the mid-run // early-finish check must agree about what green means. v := classifySmoke(cmd, sr) @@ -925,11 +1025,16 @@ func (o *Orchestrator) qaGateNoVerdict(cmd string) bool { return false } -// changedFingerprint identifies the set of files the run has written so far. -// Two equal fingerprints mean no write happened in between, so any command that -// was already run against the tree would answer the same way again. +// changedFingerprint identifies what the run has written so far: the write +// sequence and the set of files. Two equal fingerprints mean no write happened +// in between, so any command that was already run against the tree would +// answer the same way again. +// +// The sequence is part of it on purpose. A path set alone reads a corrector +// rewriting an already-changed file as "wrote nothing", which is how a fix +// pass that did fix something was scored as a stall. func (o *Orchestrator) changedFingerprint() string { - return strings.Join(o.changedFilesSnapshot(), "\n") + return fmt.Sprintf("%d\n%s", o.writeSequence(), strings.Join(o.changedFilesSnapshot(), "\n")) } // qaCommand resolves the project's test/smoke command. @@ -997,6 +1102,9 @@ func (o *Orchestrator) formatWaveChanges(ctx context.Context) { }) if fixOut != "" { o.emit("test", "qa_gate: formatted changed files: "+truncate(fixOut, 200), "") + // The formatter rewrote files outside the tool layer's hook: a + // command result remembered before it must not be reused after it. + o.noteTreeMutation() } } @@ -1026,6 +1134,14 @@ func (o *Orchestrator) runQABootstrap(ctx context.Context, cmd string) { o.emit("test", "qa_gate bootstrap: "+truncate(bp.Command, 120)+ " (policy="+string(bp.Policy)+")", "") + // Once per tree state. The pre-test and the QA gate both bootstrap, and + // the second install on a tree nothing has written to since is the same + // install; the memo entry below is what says it already happened. + if key, _ := o.smokeMemoKey(bp.Command); o.hasMemoizedSmoke(key) { + o.emitFull("test", stream.KindDebug, "qa", "", + "qa_gate bootstrap already applied to this tree — not re-running "+truncate(bp.Command, 80), "", "") + return + } var sr quality.SmokeResult if bp.NeedsApproval { o.emitWarn("test", truncate(bp.Reason, 240), "") @@ -1035,6 +1151,14 @@ func (o *Orchestrator) runQABootstrap(ctx context.Context, cmd string) { // exactly as quality.RunAcceptanceSmokeWithPolicy does for Run plans. sr = o.runSmoke(ctx, bp.Command) } + if sr.Ran { + // Dependencies landed outside the tool layer's hook: whatever the + // objective command answered BEFORE them no longer describes this + // tree. The install itself is remembered against the tree it + // produced, so it is not taken again for the same reason. + o.noteTreeMutation() + o.rememberSmokeNow(bp.Command, sr) + } _ = o.store.Append(contextstore.DocScratch, "QA bootstrap", fmt.Sprintf("cmd: %s\npolicy: %s\nran=%v ok=%v\n\n%s", bp.Command, bp.Policy, sr.Ran, sr.OK, quality.FailureExcerpt(sr.Output, 2000))) diff --git a/pkg/orchestrator/qagate_cancel_test.go b/pkg/orchestrator/qagate_cancel_test.go index 8e84f14..b4143be 100644 --- a/pkg/orchestrator/qagate_cancel_test.go +++ b/pkg/orchestrator/qagate_cancel_test.go @@ -27,7 +27,7 @@ func TestCanceledQAGateReportsNoVerdict(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // the run is over before the gate starts - failed := o.runQAGate(ctx, "implement the thing", board) + failed := o.runQAGate(ctx, "implement the thing", board, preTest{}) if failed { t.Fatal("a canceled gate reported QAFailed=true, which fabricates a " + @@ -71,7 +71,7 @@ func TestStalledFixPassStopsInsteadOfReRunningAnUnchangedTree(t *testing.T) { t.Skipf("qa_gate_rounds is %d — this test needs room for a second round", rounds) } - failed := o.runQAGate(context.Background(), "implement the thing", midBoard()) + failed := o.runQAGate(context.Background(), "implement the thing", midBoard(), preTest{}) if !failed { t.Fatal("a red gate that stalled must still report red — stopping early " + diff --git a/pkg/orchestrator/resume.go b/pkg/orchestrator/resume.go index 945ff95..c7d4d6b 100644 --- a/pkg/orchestrator/resume.go +++ b/pkg/orchestrator/resume.go @@ -10,6 +10,7 @@ import ( "time" "github.com/UnicoLab/slmcode/pkg/agents" + "github.com/UnicoLab/slmcode/pkg/composer" contextstore "github.com/UnicoLab/slmcode/pkg/context" "github.com/UnicoLab/slmcode/pkg/knowledge" "github.com/UnicoLab/slmcode/pkg/learning" @@ -62,6 +63,12 @@ func (o *Orchestrator) Resume(ctx context.Context, turnID string) (*Result, erro o.decisions = nil o.gates = nil o.mu.Unlock() + // Same per-run resets as Run. The team state in particular: Resume used + // to restore the saved plan OVER whatever the previous run left, so a + // Studio process that resumed after a two-team run kept that run's team + // gates and could report halves proved that this run never touched. + o.resetTeamState() + o.resetRunGate() o.restoreSquadPlan(&board) defer func() { o.mu.Lock() @@ -246,7 +253,7 @@ func (o *Orchestrator) finalizeAfterExecute(ctx context.Context, runID, query, s return o.finishObjectiveMet(ctx, runID, query, skillPack, board, g, "after the corrective wave", start) } - gate := o.runQualityGates(ctx, query, board, runner, testerRejected) + gate := o.runQualityGates(ctx, query, board, runner, testerRejected, pre) board = gate.Board testerRejected = gate.TesterRejected @@ -298,6 +305,17 @@ type preTest struct { // run of the objective command instead of paying for an identical second // one. Nil when the pre-test did not run. Smoke *quality.SmokeResult + // Fingerprint is the tree fingerprint the command was run against, so a + // later consumer (the QA gate's first round) can tell whether Smoke still + // describes the tree in front of it. + Fingerprint string +} + +// reusableFor reports whether this pre-test's result can stand in for a run +// of cmd against a tree whose fingerprint is now fp. +func (p preTest) reusableFor(cmd, fp string) bool { + return p.Smoke != nil && p.Smoke.Ran && p.Ran && + strings.TrimSpace(p.Cmd) == strings.TrimSpace(cmd) && p.Fingerprint == fp } func (o *Orchestrator) runDeterministicPreTest(ctx context.Context) preTest { @@ -314,6 +332,7 @@ func (o *Orchestrator) runDeterministicPreTest(ctx context.Context) preTest { // the permission layer, auto installs. A manifest the worker wrote moments // ago is not consent to execute its install scripts. o.runQABootstrap(ctx, cmd) + pt.Fingerprint = o.changedFingerprint() sr := o.runSmoke(ctx, cmd) sr.Command = cmd // the reuse check matches on it; RunSmoke sets it too pt.Ran = true @@ -626,7 +645,7 @@ type gateOutcome struct { // runQualityGates runs placeholder fill, the reference-bar completeness check // and the QA gate, then decides what a green gate is allowed to clear. func (o *Orchestrator) runQualityGates(ctx context.Context, query string, board *plan.Board, - runner *loop.Runner, testerRejected bool) gateOutcome { + runner *loop.Runner, testerRejected bool, pre preTest) gateOutcome { out := gateOutcome{Board: board, TesterRejected: testerRejected} // Keep the run's mirrored verdict in step with whatever these gates decide. @@ -690,7 +709,7 @@ func (o *Orchestrator) runQualityGates(ctx context.Context, query string, board } out.QACmd = o.qaCommand() - qaFailed := o.runQAGate(ctx, query, out.Board) + qaFailed := o.runQAGate(ctx, query, out.Board, pre) out.QAFailed = qaFailed || integrationFailed if out.QAFailed { out.TesterRejected = true @@ -813,12 +832,27 @@ func (o *Orchestrator) completeRun(ctx context.Context, runID, query, skillPack session.SetPhase(o.cfg.SlmDir(), o.turn(), session.PhaseMemory) // pipeline gate: phaseEnabled("memory") — when=never skips distillation var lessonsMD string - if o.phaseEnabled("memory") { + // The distiller runs only when the run left something to distill: a + // lesson worth keeping (a failure, an honored human note — see + // notableLessons), a file the run changed, or a task that failed. A run + // that drained its board green without writing anything — an inquiry, a + // no-op, a rerun — used to spend a model call to be told there was nothing + // to remember. + var allLessons []learning.Lesson + notable := 0 + for _, t := range board.Tasks { + allLessons = append(allLessons, learning.Extract(t)...) + notable += notableLessons(t) + } + changedN := len(o.changedFilesSnapshot()) + failedN := board.FailedCount() + switch { + case !o.phaseEnabled("memory"): + o.emit("memory", "phase disabled — skipping memory distillation", "") + case notable == 0 && changedN == 0 && failedN == 0: + o.emit("memory", "memory distillation skipped — nothing to distill: no lessons, no changed files, no failed tasks", "") + default: o.emitAgent("memory", "memory", "", "distilling long-term memory", "", "") - var allLessons []learning.Lesson - for _, t := range board.Tasks { - allLessons = append(allLessons, learning.Extract(t)...) - } lessonsMD = learning.RenderMarkdown(allLessons) if lessonsMD != "" { _ = o.store.Append(contextstore.DocMemory, "Auto-lessons", lessonsMD) @@ -842,8 +876,11 @@ func (o *Orchestrator) completeRun(ctx context.Context, runID, query, skillPack memSkills = strings.TrimSpace(memSkills + "\n\n" + extra) } memPack, _ := o.packBuild("memory", query, contextstore.DefaultDocsForRole("memory"), nil, memSkills) - memOut, _ := o.runRoleMultipassTracked(ctx, "memory", "", memPack.Render()+fmt.Sprintf( - "\nFailed: %d\nWrite ≤8 durable bullets under ## Lessons (conventions, pitfalls, paths).", board.FailedCount())) + // Single-shot. Distillation is a summary, not a plan: the critique and + // refine passes of the multipass cycle spent up to three more model + // calls polishing eight bullets. + memOut, _ := o.runRoleTracked(ctx, "memory", "", memPack.Render()+fmt.Sprintf( + "\nFailed: %d\nWrite ≤8 durable bullets under ## Lessons (conventions, pitfalls, paths).", failedN)) if strings.TrimSpace(memOut) != "" { _ = o.store.Append(contextstore.DocMemory, "Session distillation", memOut) lessonsMD = strings.TrimSpace(lessonsMD + "\n" + memOut) @@ -859,8 +896,6 @@ func (o *Orchestrator) completeRun(ctx context.Context, runID, query, skillPack // stays the human mirror; this is what gives a lesson confidence, // contradiction handling, provenance and a prune policy. o.recordLessonFacts(allLessons) - } else { - o.emit("memory", "phase disabled — skipping memory distillation", "") } o.emit("skills", "evolving SKILLS.md + learned skill", "") @@ -1434,7 +1469,7 @@ func (o *Orchestrator) restoreSquadPlan(board *plan.Board) { o.emitWarn("init", "could not read the saved team plan ("+err.Error()+") — resuming as a single stream", "") return } - if !ok || !p.Enabled() { + if !ok || len(p.Squads) == 0 { return } stamped := false @@ -1447,8 +1482,31 @@ func (o *Orchestrator) restoreSquadPlan(board *plan.Board) { if !stamped { return } + // One squad in squads.json is a run a single library team STAFFED: its + // manager triages the resumed board's rejected work, exactly as it did + // before the interruption. It is not a squad plan — Enabled() needs two — + // so it is restored as the single team, not as o.squadPlan. + if !p.Enabled() { + st := teamChoiceFromSquad(p.Squads[0]) + o.mu.Lock() + o.singleTeam = &st + o.mu.Unlock() + o.emit("init", "restored the staffing team for this run: "+st.ID, "") + return + } o.mu.Lock() o.squadPlan = &p o.mu.Unlock() o.emit("init", "restored the team plan for this run: "+p.Summarize(), "") } + +// teamChoiceFromSquad is the inverse of singleTeamPlan: the saved one-squad +// plan read back as the composition's team choice. +func teamChoiceFromSquad(s squads.Squad) composer.TeamChoice { + return composer.TeamChoice{ + ID: s.ID, Name: s.Name, Charter: s.Charter, Owns: append([]string(nil), s.Owns...), + Acceptance: s.Acceptance, Worker: s.Worker, Reviewer: s.Reviewer, Tester: s.Tester, + Manager: s.Manager, Agents: append([]string(nil), s.Agents...), + Skills: append([]string(nil), s.Skills...), + } +} diff --git a/pkg/orchestrator/runner.go b/pkg/orchestrator/runner.go index 317e4be..38c51bf 100644 --- a/pkg/orchestrator/runner.go +++ b/pkg/orchestrator/runner.go @@ -23,7 +23,12 @@ import ( // (Resume never got OnOverflowCompact's ReactCompact notice, for one). One // constructor, two callers, one behavior. func (o *Orchestrator) buildRunner(query, runID, skillPack string) *loop.Runner { - runner := loop.NewRunner(o.executor, o.shared) + // The GATED executor: every worker, reviewer, corrector, critique, triage + // and speculative-review request the loop makes takes a slot of the same + // run-wide max_parallel gate the phase roles use (see gate.go). The wave + // is still capped at MaxParallel by the loop; the gate is what stops a + // wave's review race plus a phase still draining from exceeding it. + runner := loop.NewRunner(o.gatedExecutor(), o.shared) runner.Root = o.cfg.Root runner.SlmDir = o.cfg.SlmDir() runner.Feedback = o.LiveFeedback @@ -40,6 +45,15 @@ func (o *Orchestrator) buildRunner(query, runID, skillPack string) *loop.Runner runner.MaxParallel = o.cfg.MaxParallel runner.ReviewParallel = o.cfg.MaxParallel >= 2 runner.Timeout = o.cfg.TaskTimeout + // Measured role budgets INSIDE the loop. Timeout above is the ceiling; + // each worker/reviewer/corrector request is dispatched on + // min(ceiling clamped to runway, p95 × 1.5 floored per role class), and + // each one records a sample, so the roles that own most of a run's wall + // clock finally feed the latency memory the phase roles have used since + // roletimeout.go was written. Before this, `reviewer` had zero samples + // after a hundred runs and every reviewer call got the whole task_timeout. + runner.RoleTimeout = o.roleTimeoutWithin + runner.OnRoleLatency = o.recordRoleLatency runner.ReviewerRole = o.reviewStrictnessRole(o.Pipeline().Execute.Reviewer) runner.CorrectorRole = o.Pipeline().Execute.Corrector runner.DefaultRole = o.Pipeline().Execute.DefaultRole @@ -123,9 +137,11 @@ func (o *Orchestrator) buildRunner(query, runID, skillPack string) *loop.Runner runner.OnEscalate = func(ctx context.Context, board *plan.Board, t plan.Task, detail string) { o.runEscalateAsk(ctx, board, t, detail) } + // Usage only. The request COUNT is folded in by the gated executor, which + // sees the transcript and counts every assistant turn — one result used + // to be tallied as one call while its ReAct loop had made eight. runner.OnUsage = func(u llm.Usage, estimated bool, _, _ string) { o.recordUsage(u, estimated) - o.bumpLLMCalls(1) } runner.OnOverflowCompact = func(ctx context.Context) error { _, err := o.CompactContextNow() @@ -152,12 +168,27 @@ func (o *Orchestrator) buildRunner(query, runID, skillPack string) *loop.Runner runner.Triage = o.triageRejectedDelivery } runner.AfterWave = func(ctx context.Context, board *plan.Board, wave []plan.Task) { + // What the wave produced decides what runs after it. A wave whose + // tasks all went green with nothing to learn from used to buy a + // memory distillation AND a coordinator call — two model round-trips + // on a board nothing had happened to. Both now run only on a wave + // that failed, escalated or taught something; the reason is said so + // the Live view can show why a phase did not run. + eventful, why := waveEventful(wave) o.evolveAfterWave(ctx, query, skillPack, board, wave) o.maybeCompactContext(ctx) - o.coordinate(ctx, query, board, "after-wave") + if eventful { + o.coordinate(ctx, query, board, "after-wave") + } else { + o.emitFull("coord", stream.KindCoord, "coordinator", "", + "coordinator @after-wave skipped — "+why, "", "") + } // Per-squad progress and cross-team stalls. An aggregate task count // hides one team finishing while the other sits blocked. - o.reportSquadProgress(o.squadPlan, board) + o.reportSquadProgress(o.squadPlanNow(), board) + // A team whose lane just finished is proved NOW, so a red half's + // ticket rides the next wave instead of waiting for the finish path. + o.earlyTeamGate(ctx, board) } // The objective gate, asked BETWEEN waves rather than only after the board // drains. A board that keeps rejecting one task never drains, so the diff --git a/pkg/orchestrator/skips_test.go b/pkg/orchestrator/skips_test.go new file mode 100644 index 0000000..7d01f23 --- /dev/null +++ b/pkg/orchestrator/skips_test.go @@ -0,0 +1,166 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/pipeline" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/stream" +) + +// agentStarts lists the agents a recorder saw start. +func (r *recorder) agentStarts() []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, e := range r.events { + if e.Kind == stream.KindAgentStart { + out = append(out, e.Agent) + } + } + return out +} + +func containsAgent(starts []string, name string) bool { + for _, s := range starts { + if strings.Contains(s, name) { + return true + } + } + return false +} + +// A run whose board drained with no lessons, no changed files and no failed +// tasks has nothing for the distiller to distill — and says so instead of +// spending a model call to be told the same. +func TestMemoryPhaseIsSkippedWhenThereIsNothingToDistill(t *testing.T) { + exec := &countingExec{} + o := objectiveOrch(t, &fakeGate{ok: true}, exec, nil) + o.pipe.Phases["memory"] = pipeline.PhaseSpec{When: pipeline.WhenAlways} + o.resetChangedFiles() // the fixture pretends a file changed; this run wrote nothing + rec := &recorder{} + o.onEvent = rec.handle + + run := func() { + t.Helper() + if _, err := o.completeRun(context.Background(), "run-objective", "implement the thing", + "", doneBoard(), "", false, false, objectiveCmd, time.Now()); err != nil { + t.Fatalf("completeRun: %v", err) + } + } + + run() + if containsAgent(rec.agentStarts(), "memory") { + t.Fatalf("memory agent started on a run with nothing to distill: %v", rec.agentStarts()) + } + if exec.n() != 0 { + t.Fatalf("%d model call(s) were spent: %s", exec.n(), exec.roles()) + } + if !strings.Contains(rec.text(), "memory distillation skipped") { + t.Fatal("the skip must be announced so the Live view can show it") + } + + // The same run with one changed file has something to remember. + o.noteChangedFiles("stats.go") + run() + if !containsAgent(rec.agentStarts(), "memory") { + t.Fatalf("memory agent did not start once a file had changed: %v", rec.agentStarts()) + } + if !strings.Contains(exec.roles(), "memory") { + t.Fatalf("the distiller was announced but never called: %s", exec.roles()) + } +} + +// A one-step plan over one known file is one task: the splitter — the slowest +// role after the planner — is skipped and the board is built directly, with +// the known file as the task's scope. +func TestOneStepPlanSkipsTheSplitter(t *testing.T) { + exec := &countingExec{} + o := objectiveOrch(t, &fakeGate{ok: true}, exec, nil) + if err := os.WriteFile(filepath.Join(o.cfg.Root, "stats.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + rec := &recorder{} + o.onEvent = rec.handle + in := planSplitInput{ + RunID: "run-objective", Query: "add a greeting to stats.go", + Inventory: []string{"stats.go"}, Discovered: []string{"stats.go"}, + } + + board, err := o.runSplitPhase(context.Background(), in, + plan.Plan{Summary: "greet", Steps: []string{"Add a greeting to stats.go"}}, `{"steps":["x"]}`, nil) + if err != nil { + t.Fatalf("runSplitPhase: %v", err) + } + if containsAgent(rec.agentStarts(), "splitter") || exec.n() != 0 { + t.Fatalf("the splitter ran on a one-step plan: starts=%v calls=%s", rec.agentStarts(), exec.roles()) + } + if !strings.Contains(rec.text(), "splitter skipped") { + t.Fatal("the skip must be announced so the Live view can show it") + } + if len(board.Tasks) != 1 { + t.Fatalf("board has %d task(s), want the plan's one step: %+v", len(board.Tasks), board.Tasks) + } + got := board.Tasks[0] + if got.Column != plan.ColReadyToDev || strings.Join(got.Files, ",") != "stats.go" { + t.Fatalf("the one task must be ready with the known file as scope, got column=%q files=%v notes=%q", + got.Column, got.Files, got.Notes) + } + + // Two steps are a real split: the splitter is asked. + rec2 := &recorder{} + o.onEvent = rec2.handle + if _, err := o.runSplitPhase(context.Background(), in, + plan.Plan{Summary: "two", Steps: []string{"one", "two"}}, `{"steps":["one","two"]}`, nil); err != nil { + t.Fatalf("runSplitPhase: %v", err) + } + if !containsAgent(rec2.agentStarts(), "splitter") { + t.Fatalf("a two-step plan must still be split by the splitter: %v", rec2.agentStarts()) + } +} + +// After a wave, the coordinator and the wave distiller run only when the wave +// gave them something: a failure, an escalation or a lesson. +func TestQuietWavesAreNotEventful(t *testing.T) { + // Realistic green tasks: an acceptance and an output, which the extractor + // turns into routine "success"/"convention" lessons that must NOT count. + quiet := []plan.Task{ + {ID: "T1", Title: "a", Column: plan.ColDone, Status: "done", Acceptance: "file exists", + Output: `{"status":"done","summary":"wrote a.go","files_changed":["a.go"]}`}, + {ID: "T2", Title: "b", Column: plan.ColDone, Status: "done", Acceptance: "tests pass", + Output: `{"status":"done","summary":"wrote b.go","files_changed":["b.go"]}`}, + } + if ok, why := waveEventful(quiet); ok || !strings.Contains(why, "green") { + t.Fatalf("a green wave with nothing learned is eventful: %v %q", ok, why) + } + if ok, _ := waveEventful(nil); ok { + t.Fatal("an empty wave is eventful") + } + failed := append([]plan.Task{}, quiet...) + failed = append(failed, plan.Task{ID: "T3", Column: plan.ColBlocked, Error: "build failed"}) + if ok, _ := waveEventful(failed); !ok { + t.Fatal("a wave with a blocked task is not eventful") + } + escalated := append([]plan.Task{}, quiet...) + escalated = append(escalated, plan.Task{ID: "T4", Column: plan.ColToScope, Notes: "escalated after max retries"}) + if ok, _ := waveEventful(escalated); !ok { + t.Fatal("a wave with an escalated task is not eventful") + } + // A human note the task honored is worth remembering; a harness + // bookkeeping marker in Notes is not. + learned := []plan.Task{{ID: "T5", Title: "c", Column: plan.ColDone, Status: "done", + Notes: "Keep the encoder order: set Content-Type before writing the body."}} + if ok, _ := waveEventful(learned); !ok { + t.Fatal("a wave that honored a human note is not eventful") + } + bookkeeping := []plan.Task{{ID: "T6", Title: "d", Column: plan.ColDone, Status: "done", + Notes: "correction-key: tester|x|a.go\ncorrection-attempt: 1"}} + if ok, _ := waveEventful(bookkeeping); ok { + t.Fatal("harness bookkeeping in Notes made a wave eventful") + } +} diff --git a/pkg/orchestrator/smokememo_test.go b/pkg/orchestrator/smokememo_test.go new file mode 100644 index 0000000..407d589 --- /dev/null +++ b/pkg/orchestrator/smokememo_test.go @@ -0,0 +1,184 @@ +package orchestrator + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/quality" + ggagent "github.com/piotrlaczkowski/GoLangGraph/pkg/agent" +) + +// The memo: a command asked of a tree nothing has written to since it last +// answered is answered from memory; any write — a NEW file or a REWRITE of one +// already changed — or a non-agent mutation invalidates it. +func TestSmokeMemoAnswersAnUnchangedTreeOnce(t *testing.T) { + o := testOrch(t, nil) + var mu sync.Mutex + runs := map[string]int{} + o.qaSmoke = func(_ context.Context, _, cmd string, _ time.Duration) quality.SmokeResult { + mu.Lock() + runs[cmd]++ + mu.Unlock() + return quality.SmokeResult{Ran: true, OK: true, Command: cmd, Output: "ok", Duration: time.Second} + } + count := func(cmd string) int { + mu.Lock() + defer mu.Unlock() + return runs[cmd] + } + ctx := context.Background() + const cmd = "go test ./..." + + o.runSmoke(ctx, cmd) + o.runSmoke(ctx, cmd) + o.runSmokeIn(ctx, cmd, 30*time.Second) // a different timeout is the same question + if n := count(cmd); n != 1 { + t.Fatalf("unchanged tree: command ran %d time(s), want 1", n) + } + + o.noteChangedFiles("x.go") + o.runSmoke(ctx, cmd) + if n := count(cmd); n != 2 { + t.Fatalf("after a write: %d run(s), want 2", n) + } + + // A rewrite of the SAME file is a change: the path set is unchanged, the + // write sequence is not. + o.noteChangedFiles("x.go") + o.runSmoke(ctx, cmd) + if n := count(cmd); n != 3 { + t.Fatalf("after a rewrite of an already-changed file: %d run(s), want 3", n) + } + + // A mutation outside the tool layer (formatter, dependency install). + o.noteTreeMutation() + o.runSmoke(ctx, cmd) + if n := count(cmd); n != 4 { + t.Fatalf("after a tree mutation: %d run(s), want 4", n) + } + + // Another command is another question. + o.runSmoke(ctx, "npm test") + o.runSmoke(ctx, "npm test") + if n := count("npm test"); n != 1 { + t.Fatalf("second command ran %d time(s), want 1", n) + } + if n := count(cmd); n != 4 { + t.Fatalf("the first command's memo was disturbed by the second: %d", n) + } + + // A new run forgets everything. + o.resetObjectiveProbes() + o.runSmoke(ctx, cmd) + if n := count(cmd); n != 5 { + t.Fatalf("after a run reset: %d run(s), want 5", n) + } +} + +// What is NOT remembered: a command that could not start, and one that hit +// its timeout — the second answers "at least this long", not "it fails". +func TestSmokeMemoRefusesUnusableResults(t *testing.T) { + o := testOrch(t, nil) + var mu sync.Mutex + n := 0 + var result quality.SmokeResult + o.qaSmoke = func(_ context.Context, _, cmd string, _ time.Duration) quality.SmokeResult { + mu.Lock() + n++ + mu.Unlock() + r := result + r.Command = cmd + return r + } + ctx := context.Background() + + result = quality.SmokeResult{Ran: false, Summary: "npm: not found"} + o.runSmoke(ctx, "npm test") + o.runSmoke(ctx, "npm test") + if n != 2 { + t.Fatalf("a result that never ran was remembered: %d run(s)", n) + } + + result = quality.SmokeResult{Ran: true, OK: false, Duration: 30 * time.Second, Summary: "killed"} + o.runSmokeIn(ctx, "slow", 30*time.Second) + o.runSmokeIn(ctx, "slow", 30*time.Second) + if n != 4 { + t.Fatalf("a timed-out result was remembered: %d run(s)", n) + } +} + +// passingExec is a countingExec whose tester PASSES with shell evidence, so a +// run can travel the whole finish path without a corrective wave. +type passingExec struct{ countingExec } + +func (p *passingExec) ExecuteSubAgents(ctx context.Context, reqs []ggagent.SubAgentRequest, + shared *ggagent.SharedState) ([]ggagent.SubAgentResult, error) { + out, err := p.countingExec.ExecuteSubAgents(ctx, reqs, shared) + for i, r := range reqs { + if strings.Contains(r.AgentID, "tester") { + out[i].Output = "Observation: ws_shell `" + objectiveCmd + "` exit status 0\nok\tstats\t0.2s\n" + + `{"passed":true,"commands":["` + objectiveCmd + `"],"summary":"green","failures":[]}` + } + } + return out, err +} + +// The finish path used to run the objective command up to four times on an +// unchanged tree: the pre-test, the team gate, QA round 1, the final check. +// With nothing written after the pre-test, it runs ONCE. +// +// The baseline is marked green so the early finish does not take the short +// way out (a green that was green before verifies nothing): the run has to go +// through the tester, the quality gates and the QA gate's first round, which +// is exactly the stretch where the repeated runs used to happen. +func TestFinishPathRunsTheObjectiveCommandOnceOnAnUnchangedTree(t *testing.T) { + gate := &fakeGate{ok: true, out: "ok\tstats\t0.2s\n"} + exec := &passingExec{} + o := objectiveOrch(t, gate, exec, nil) + o.mu.Lock() + o.objective.baselineKnown, o.objective.baselineGreen = true, true + o.mu.Unlock() + + res := finalize(t, o, doneBoard()) + + if n := gate.count(objectiveCmd); n != 1 { + t.Fatalf("objective command ran %d time(s) through the finish path on an unchanged tree, want exactly 1", + n) + } + if o.lastRunner().CorrectiveRuns() != 0 { + t.Fatalf("a passing tester still scheduled corrective waves: %s", res.Summary) + } +} + +// The QA gate's first round says, in its own events, that it reused the +// pre-test — and re-runs when the tree moved in between. +func TestQAGateFirstRoundReusesThePreTest(t *testing.T) { + gate := &fakeGate{ok: true, out: "ok\tstats\t0.2s\n"} + o := objectiveOrch(t, gate, &countingExec{}, nil) + rec := &recorder{} + o.onEvent = rec.handle + + pre := o.runDeterministicPreTest(context.Background()) + if !pre.Ran || pre.Fingerprint == "" { + t.Fatalf("pre-test did not run or carries no fingerprint: %+v", pre) + } + if red := o.runQAGate(context.Background(), "implement the thing", doneBoard(), pre); red { + t.Fatal("a green pre-test made the QA gate red") + } + if n := gate.count(objectiveCmd); n != 1 { + t.Fatalf("QA round 1 re-ran the command (%d total) instead of reusing the pre-test", n) + } + if !strings.Contains(rec.text(), "reusing the pre-test") { + t.Fatal("the reuse is not visible in the gate's events") + } + + // The tree moved: the pre-test no longer describes it. + o.noteChangedFiles("stats.go") + _ = o.runQAGate(context.Background(), "implement the thing", doneBoard(), pre) + if n := gate.count(objectiveCmd); n != 2 { + t.Fatalf("after a write the gate still reused the stale pre-test (%d runs)", n) + } +} diff --git a/pkg/orchestrator/squads.go b/pkg/orchestrator/squads.go index 866779e..0883cd5 100644 --- a/pkg/orchestrator/squads.go +++ b/pkg/orchestrator/squads.go @@ -287,6 +287,13 @@ func (o *Orchestrator) runSquadIntegration(ctx context.Context, board *plan.Boar return false } + // The join is a command run on the finish path, and the finish path has a + // reserve to keep. Skipping with a reason beats overrunning the report. + if !o.gateRoundAffordable(ctx, 2) { + o.emitWarn("integrate", "integration skipped — not enough time left to run "+gate.Command+ + " and still report", "") + return false + } o.emit("integrate", "every squad is green — joining the halves: "+gate.Command, "") res := o.runSmoke(ctx, gate.Command) if !res.Ran { diff --git a/pkg/orchestrator/teamgate.go b/pkg/orchestrator/teamgate.go index 7663fec..c6298e7 100644 --- a/pkg/orchestrator/teamgate.go +++ b/pkg/orchestrator/teamgate.go @@ -68,6 +68,29 @@ func (g TeamGate) Verified() bool { return g.Ran } type teamGates struct { mu sync.RWMutex by map[string]TeamGate + // lane records, per team, the fingerprint of the team's own changed files + // at the moment its half was last proved. The between-wave gate re-proves + // a half only when this differs — see earlyTeamGate. + lane map[string]string +} + +// provedOn records the lane fingerprint a team's gate was taken against. +func (t *teamGates) provedOn(team, fingerprint string) { + t.mu.Lock() + defer t.mu.Unlock() + if t.lane == nil { + t.lane = map[string]string{} + } + t.lane[team] = fingerprint +} + +// lastLane returns the lane fingerprint the team was last proved against and +// whether it was ever proved this run. +func (t *teamGates) lastLane(team string) (string, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + fp, ok := t.lane[team] + return fp, ok } func (t *teamGates) set(g TeamGate) { @@ -79,6 +102,36 @@ func (t *teamGates) set(g TeamGate) { t.by[g.Team] = g } +// reset forgets every result. Called at the start of a run: a gate is evidence +// about THIS run's tree, and the orchestrator outlives a run. +func (t *teamGates) reset() { + t.mu.Lock() + defer t.mu.Unlock() + t.by = nil + t.lane = nil +} + +// resetTeamState clears every piece of per-run team state — the squad plan, +// the single staffing team and the team gates — under the lock. +// +// Why all three, and why at the START of both Run and Resume: the squad plan +// used to be cleared halfway through runSLM and never on Resume, and the +// gates were never cleared at all. runTeamAcceptance skips teams with no work +// (Total == 0), so a previous run's green gate for a team that sits idle in +// this run survived, and allHalvesProved then read two green gates on a run +// that proved one half — upgrading a failed run's verdict on stale evidence. +func (o *Orchestrator) resetTeamState() { + if o == nil { + return + } + o.mu.Lock() + o.squadPlan = nil + o.singleTeam = nil + o.teamPick = nil + o.mu.Unlock() + o.teamGates.reset() +} + func (t *teamGates) snapshot() []TeamGate { t.mu.RLock() defer t.mu.RUnlock() @@ -109,17 +162,18 @@ func (o *Orchestrator) TeamGates() []TeamGate { // build` writing dist/ while another's `go test` reads the same tree produces a // failure neither team caused. func (o *Orchestrator) runTeamAcceptance(ctx context.Context, board *plan.Board) []string { - if o == nil || o.squadPlan == nil || board == nil || len(o.squadPlan.Squads) < 2 { + p := o.squadPlanNow() + if o == nil || p == nil || board == nil || len(p.Squads) < 2 { return nil } progress := map[string]squads.Status{} - for _, st := range squads.Progress(o.squadPlan, board.Tasks) { + for _, st := range squads.Progress(p, board.Tasks) { progress[st.ID] = st } var failed []string - for _, s := range o.squadPlan.Squads { + for _, s := range p.Squads { st := progress[s.ID] // A team with no work did nothing to prove. Running its acceptance // would report on the state of the repository, not on this run. @@ -129,67 +183,176 @@ func (o *Orchestrator) runTeamAcceptance(ctx context.Context, board *plan.Board) if ctx.Err() != nil { return failed } - - cmd := strings.TrimSpace(s.Acceptance) - if cmd == "" { - o.teamGates.set(TeamGate{Team: s.ID, Summary: "no acceptance command — this half was never proved"}) - o.emitWarn("verify", "team "+s.ID+" has no acceptance command — its half is unproved, "+ - "so a break in it can only surface at integration", "") + // Each half is a command run, and the finish path has a reserve to + // keep: a run that cannot afford another command run must say so + // rather than overrun on the way to its own report. + if !o.gateRoundAffordable(ctx, 2) { + o.emitWarn("verify", "team "+s.ID+": not enough time left to run its acceptance and still report — "+ + "its half is UNVERIFIED", "") + o.teamGates.set(TeamGate{Team: s.ID, Command: strings.TrimSpace(s.Acceptance), + Summary: "not run — out of time"}) continue } - - // The team is not wrong about wanting its half to compile, only about - // what THIS project calls that. A shipped team declares - // `npm --prefix web run build`; a scaffold may name it `compile`, or - // have only `typecheck`. Resolving it is the difference between the - // half being proved and being permanently grey. - root := "" - if o.cfg != nil { - root = o.cfg.Root - } - if resolved, note := quality.ResolveScriptCommand(root, cmd); note != "" { - o.emit("verify", "team "+s.ID+": "+note, "") - cmd = resolved + if g, red := o.proveHalf(ctx, board, p, s); red { + failed = append(failed, g.Team) } + } + return failed +} + +// squadPlanNow reads the run's squad plan under the lock. +func (o *Orchestrator) squadPlanNow() *squads.Plan { + if o == nil { + return nil + } + o.mu.Lock() + defer o.mu.Unlock() + return o.squadPlan +} - o.emit("verify", "team "+s.ID+": proving its half alone — "+cmd, "") - res := o.runSmoke(ctx, cmd) - g := TeamGate{Team: s.ID, Command: cmd, Ran: res.Ran, OK: res.OK, Summary: res.Summary} +// proveHalf runs ONE team's acceptance command, records the gate and raises the +// team's ticket when its half is red. red is true only for a verdict that RAN +// and failed; an unrunnable check is UNVERIFIED and never red. +// +// The command goes through runSmoke, so a half already proved on this exact +// tree — by the between-wave gate, say — is answered from the run's smoke memo +// rather than by running the suite again. +func (o *Orchestrator) proveHalf(ctx context.Context, board *plan.Board, p *squads.Plan, s squads.Squad) (TeamGate, bool) { + lane := o.laneFingerprint(p, s.ID) + cmd := strings.TrimSpace(s.Acceptance) + if cmd == "" { + g := TeamGate{Team: s.ID, Summary: "no acceptance command — this half was never proved"} o.teamGates.set(g) + o.teamGates.provedOn(s.ID, lane) + o.emitWarn("verify", "team "+s.ID+" has no acceptance command — its half is unproved, "+ + "so a break in it can only surface at integration", "") + return g, false + } - // A check that never ran is a fact about the MACHINE or the project, - // not about the code. `npm run build` with node_modules never - // installed, or with no build script in package.json, exits non-zero - // and says nothing whatsoever about what the team wrote — scoring that - // red sends a corrector to rewrite source that was never at fault, - // burns the retry budget, and shows the user a red team for something - // no model can fix. Both were measured live; the second is why this - // asks CheckDidNotRun rather than ToolingMissing alone. - if why := quality.CheckDidNotRun(cmd, res.Output); res.Ran && !res.OK && why != "" { - g.Ran = false - g.Summary = why + ": " + res.Summary - o.teamGates.set(g) + // The team is not wrong about wanting its half to compile, only about + // what THIS project calls that. A shipped team declares + // `npm --prefix web run build`; a scaffold may name it `compile`, or + // have only `typecheck`. Resolving it is the difference between the + // half being proved and being permanently grey. + root := "" + if o.cfg != nil { + root = o.cfg.Root + } + if resolved, note := quality.ResolveScriptCommand(root, cmd); note != "" { + o.emit("verify", "team "+s.ID+": "+note, "") + cmd = resolved + } + + o.emit("verify", "team "+s.ID+": proving its half alone — "+cmd, "") + res := o.runSmoke(ctx, cmd) + g := TeamGate{Team: s.ID, Command: cmd, Ran: res.Ran, OK: res.OK, Summary: res.Summary} + o.teamGates.set(g) + o.teamGates.provedOn(s.ID, lane) + + // A check that never ran is a fact about the MACHINE or the project, + // not about the code. `npm run build` with node_modules never + // installed, or with no build script in package.json, exits non-zero + // and says nothing whatsoever about what the team wrote — scoring that + // red sends a corrector to rewrite source that was never at fault, + // burns the retry budget, and shows the user a red team for something + // no model can fix. Both were measured live; the second is why this + // asks CheckDidNotRun rather than ToolingMissing alone. + if why := quality.CheckDidNotRun(cmd, res.Output); res.Ran && !res.OK && why != "" { + g.Ran = false + g.Summary = why + ": " + res.Summary + o.teamGates.set(g) + } + + switch { + case !g.Ran: + // A missing runner is a fact about the machine. Failing a team for + // it turns a working run red for a reason the model cannot fix. + o.emitWarn("verify", "team "+s.ID+": acceptance could not run ("+g.Summary+ + ") — its half is UNVERIFIED, not broken", "") + return g, false + case res.OK: + o.emit("verify", "team "+s.ID+" is green: "+cmd, "") + o.recordGate("team:"+s.ID, true, "") + return g, false + default: + o.emitWarn("verify", "team "+s.ID+" is RED — its own half does not pass: "+res.Summary, res.Output) + o.recordGate("team:"+s.ID, false, res.Summary) + o.raiseTeamTicket(board, s, cmd, res.Summary, res.Output) + return g, true + } +} + +// laneFingerprint identifies what this run has written INSIDE one team's +// territory: the changed files the plan says the team owns, each with its +// write count so a rewrite of an already-changed file counts. Two equal +// fingerprints mean the team's half has not moved since — and the OTHER +// team's writes do not move it, which is what lets a finished lane stay +// proved while its neighbor keeps working. +func (o *Orchestrator) laneFingerprint(p *squads.Plan, team string) string { + if o == nil || p == nil { + return "" + } + var mine []string + for _, fw := range o.changedFileWrites() { + if owner, ok := p.Owner(fw.Path); ok && owner == team { + mine = append(mine, fmt.Sprintf("%s:%d", fw.Path, fw.Writes)) } + } + return strings.Join(mine, "\n") +} - switch { - case !g.Ran: - // A missing runner is a fact about the machine. Failing a team for - // it turns a working run red for a reason the model cannot fix. - o.emitWarn("verify", "team "+s.ID+": acceptance could not run ("+g.Summary+ - ") — its half is UNVERIFIED, not broken", "") - case res.OK: - o.emit("verify", "team "+s.ID+" is green: "+cmd, "") - o.recordGate("team:"+s.ID, true, "") - default: - failed = append(failed, s.ID) - o.emitWarn("verify", "team "+s.ID+" is RED — its own half does not pass: "+res.Summary, res.Output) - o.recordGate("team:"+s.ID, false, res.Summary) - o.raiseTeamTicket(board, s, cmd, res.Summary, res.Output) +// earlyTeamGate proves a half the moment its lane finishes, between waves. +// +// The finish path proves every half once the WHOLE board has drained. On a +// two-team run that is late: the backend can finish in wave 1 with a broken +// build and sit green-on-the-board for every wave the frontend still needs, +// its ticket raised only after the last frontend task — at which point the fix +// is a whole extra wave the run may no longer have time for. Proving a lane +// when it completes puts the ticket on the board for the NEXT wave. +// +// A team is proved here when squads.Progress says it is Complete and its lane +// has changed since it was last proved (or was never proved this run). A team +// that never wrote inside its own territory is not proved: its acceptance +// would report on the repository, not on this run. Commands go through the +// smoke memo, so the finish-path gate re-runs nothing the tree has not moved +// under since. +func (o *Orchestrator) earlyTeamGate(ctx context.Context, board *plan.Board) { + p := o.squadPlanNow() + if o == nil || p == nil || board == nil || len(p.Squads) < 2 || ctx.Err() != nil { + return + } + for _, st := range squads.Progress(p, board.Tasks) { + if !st.Complete { + continue + } + s, ok := p.Squad(st.ID) + if !ok { + continue + } + lane := o.laneFingerprint(p, s.ID) + if lane == "" { + // Nothing written in this team's territory: nothing to prove yet. + continue + } + if last, proved := o.teamGates.lastLane(s.ID); proved && last == lane { + continue + } + if !o.gateRoundAffordable(ctx, 2) { + o.emit("verify", "team "+s.ID+" finished its lane, but there is not enough time left to prove "+ + "it between waves — deferring to the finish path", "") + return + } + o.emit("verify", "team "+s.ID+" finished its lane — proving its half before the next wave", "") + if g, red := o.proveHalf(ctx, board, p, s); red { + o.emit("verify", "team "+g.Team+"'s correction ticket rides the next wave", "") } } - return failed } +// maxTeamTicketsPerDefect bounds how many times ONE failing acceptance is +// ticketed to its team per run: the first ticket and one retry. +const maxTeamTicketsPerDefect = 2 + // raiseTeamTicket turns a red half into a ticket the owning team can work. // // Scoped to the team's own lane. A ticket for a broken Go build must not name @@ -208,7 +371,7 @@ func (o *Orchestrator) raiseTeamTicket(board *plan.Board, s squads.Squad, cmd, s // and half of that can belong to the other side of the seam. var mine []string for _, path := range squads.PathsIn(output) { - if owner, ok := o.squadPlan.Owner(path); ok && owner == s.ID { + if owner, ok := o.squadPlanNow().Owner(path); ok && owner == s.ID { mine = append(mine, path) } } @@ -221,17 +384,30 @@ func (o *Orchestrator) raiseTeamTicket(board *plan.Board, s squads.Squad, cmd, s Output: output, Files: limitList(mine, 6), Squad: s.ID, - Attempt: board.CorrectionAttempts(plan.CorrectionKey(plan.CorrectionInput{ - Source: plan.SourceTester, Command: cmd, Squad: s.ID, - })), } key := plan.CorrectionKey(in) + // Counted under the SAME key the ticket is stamped with. It used to be + // looked up under a reduced key (source, command, squad) that no stamped + // ticket ever matched, so every ticket announced itself as attempt 1 and + // nothing could tell a first ticket from a tenth. + in.Attempt = board.CorrectionAttempts(key) // The same red half on a second pass is the SAME defect. A second ticket // would make the board look like it is losing ground while one unresolved // break stacks tickets on every gate run. if board.NoteRepeatedRejection(key) > 0 { return } + // And the same defect, ticketed, worked to done, and red AGAIN, is a + // defect the team cannot fix on its own. The finish-path gate ran once + // and was bounded by that; the between-wave gate is not, and without this + // cap a half that stays red would be ticketed on every wave until the run + // hit its ceiling — measured at 200 tickets in a test before the cap + // existed. Two attempts per defect, then a human. + if in.Attempt >= maxTeamTicketsPerDefect { + o.emitWarn("verify", fmt.Sprintf("team %s is still RED after %d correction ticket(s) for the same "+ + "failure — not raising another; this half needs a human", s.ID, in.Attempt), "") + return + } nt := plan.NewCorrectionTicket(in, hasRole) plan.StampCorrectionKey(&nt, key) plan.StampCorrectionAttempt(&nt, in.Attempt+1) diff --git a/pkg/orchestrator/teamstate_test.go b/pkg/orchestrator/teamstate_test.go new file mode 100644 index 0000000..f7db704 --- /dev/null +++ b/pkg/orchestrator/teamstate_test.go @@ -0,0 +1,147 @@ +package orchestrator + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/config" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/quality" + "github.com/UnicoLab/slmcode/pkg/squads" +) + +// Two runs on one Orchestrator. The first proves two halves green; the second +// has one team idle. Its verdict must NOT read the first run's gate for the +// idle team — which is exactly what happened when the gates were never reset: +// runTeamAcceptance skips Total==0 teams, so the stale green survived and +// allHalvesProved upgraded a run that proved one half. +func TestTeamGatesDoNotSurviveIntoTheNextRun(t *testing.T) { + o, _ := gateOrchestrator(t, func(cmd string) quality.SmokeResult { + return quality.SmokeResult{Ran: true, OK: true, Command: cmd, Summary: "ok"} + }) + + // Run 1: both halves have work, both prove green. + o.runTeamAcceptance(context.Background(), boardWithBothHalves()) + if !o.allHalvesProved() { + t.Fatal("run 1: two green halves must count as proved") + } + + // Run 2 begins the way runSLM and Resume begin. + o.resetTeamState() + if o.squadPlanNow() != nil || len(o.TeamGates()) != 0 { + t.Fatalf("reset left team state behind: plan=%v gates=%+v", o.squadPlanNow(), o.TeamGates()) + } + o.mu.Lock() + o.squadPlan = twoTeams() + o.mu.Unlock() + oneHalf := &plan.Board{Tasks: []plan.Task{ + {ID: "T1", Squad: "backend", Column: plan.ColDone, Status: "done", Files: []string{"cmd/main.go"}}, + }} + o.runTeamAcceptance(context.Background(), oneHalf) + + if gates := o.TeamGates(); len(gates) != 1 || gates[0].Team != "backend" { + t.Fatalf("run 2 gates = %+v, want only the team that had work", gates) + } + if o.allHalvesProved() { + t.Fatal("run 2 proved ONE half and reported both — the previous run's gate leaked in") + } +} + +// The reset must happen at the top of both entry points, before anything can +// read or set team state. Pinned on the source so a refactor that moves it +// back below the charter phase, or drops it from Resume, fails here. +func TestBothRunEntryPointsResetTeamStateFirst(t *testing.T) { + body := func(file, fn string) string { + t.Helper() + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + i := strings.Index(string(src), fn) + if i < 0 { + t.Fatalf("%s not found in %s", fn, file) + } + return string(src[i:]) + } + run := body("orchestrator.go", "func (o *Orchestrator) runSLM(") + reset, assemble := strings.Index(run, "o.resetTeamState()"), strings.Index(run, "o.assembleSquads(") + if reset < 0 || assemble < 0 || reset > assemble { + t.Fatalf("runSLM must reset team state before the charter phase (reset at %d, assemble at %d)", reset, assemble) + } + resume := body("resume.go", "func (o *Orchestrator) Resume(") + reset, restore := strings.Index(resume, "o.resetTeamState()"), strings.Index(resume, "o.restoreSquadPlan(") + if reset < 0 || restore < 0 || reset > restore { + t.Fatalf("Resume must reset team state before restoring the saved plan (reset at %d, restore at %d)", reset, restore) + } +} + +// squads.json holding ONE squad is a run a single library team staffed. Resume +// restores it as the staffing team — its manager triages the resumed board's +// rejected work — rather than dropping it because one squad is not a plan. +func TestRestoreSquadPlanRestoresASingleStaffingTeam(t *testing.T) { + o := testOrch(t, func(c *config.Config) { c.Squads = true }) + one := squads.Plan{Squads: []squads.Squad{{ + ID: "backend-go", Name: "Backend", Owns: []string{"cmd/**"}, Acceptance: "go test ./...", + Manager: "manager", Worker: "go-worker", + }}} + if err := squads.Save(o.cfg.SlmDir(), one); err != nil { + t.Fatal(err) + } + board := &plan.Board{Tasks: []plan.Task{ + {ID: "T1", Squad: "backend-go", Column: plan.ColReadyToDev, Files: []string{"cmd/main.go"}}, + }} + + o.restoreSquadPlan(board) + + if o.squadPlanNow() != nil { + t.Fatal("one squad was restored as a squad PLAN — Enabled() needs two") + } + st := o.singleTeamStaffing() + if st.Squad != "backend-go" || st.Manager != "manager" { + t.Fatalf("staffing after restore = %+v, want the saved team and its manager", st) + } + + // A board no team touched gets nothing, as before. + o.resetTeamState() + o.restoreSquadPlan(&plan.Board{Tasks: []plan.Task{{ID: "T1", Column: plan.ColReadyToDev}}}) + if o.singleTeamStaffing().Squad != "" { + t.Fatal("a board with no stamped task must not restore a team") + } +} + +// The team library selection is computed once per run and cleared with the +// rest of the team state. +func TestTeamSelectionIsMemoizedPerRun(t *testing.T) { + o := testOrch(t, nil) + o.cfg.TeamLibrary = true + sel1, _ := o.preselectTeamsWith("add a Go API endpoint", nil, nil) + o.mu.Lock() + pick := o.teamPick + o.mu.Unlock() + if len(o.teamRoster()) > 0 && pick == nil { + t.Fatal("the run's selection was not memoized") + } + sel2, _ := o.preselectTeamsWith("add a Go API endpoint", nil, nil) + if strings.Join(sel1.IDs(), ",") != strings.Join(sel2.IDs(), ",") { + t.Fatalf("memoized selection differs: %v vs %v", sel1.IDs(), sel2.IDs()) + } + // A preview with explicit pins never reads or writes the run's cache. + o.preselectTeamsWith("add a Go API endpoint", nil, []string{"frontend-react"}) + o.mu.Lock() + same := o.teamPick == pick + o.mu.Unlock() + if !same { + t.Fatal("a pinned preview overwrote the run's memoized selection") + } + o.resetTeamState() + o.mu.Lock() + cleared := o.teamPick == nil + o.mu.Unlock() + if !cleared { + t.Fatal("resetTeamState left the previous run's selection behind") + } + _ = time.Now() +} diff --git a/pkg/plan/tasks.go b/pkg/plan/tasks.go index 7219813..d4da4d0 100644 --- a/pkg/plan/tasks.go +++ b/pkg/plan/tasks.go @@ -142,32 +142,98 @@ func (b *Board) ReadyTasks() []Task { return b.executableTasksLocked() } +// BoardHook observes one task after a change to it has been applied. The +// task is passed by value (a snapshot), and hooks run AFTER boardMu is +// released, so a hook may read the board or emit an event without deadlocking. +// +// Hooks are package-level for the same reason boardMu is: Board is copied by +// value at every boundary, so a hook stored on the struct would be lost in +// the copy. Studio installs one to publish task_update events. +type BoardHook func(t Task) + +var ( + boardHookMu sync.RWMutex + boardHooks = map[int]BoardHook{} + boardHookID int +) + +// AddBoardHook registers fn and returns a function that removes it. +func AddBoardHook(fn BoardHook) (remove func()) { + if fn == nil { + return func() {} + } + boardHookMu.Lock() + boardHookID++ + id := boardHookID + boardHooks[id] = fn + boardHookMu.Unlock() + return func() { + boardHookMu.Lock() + delete(boardHooks, id) + boardHookMu.Unlock() + } +} + +// fireBoardHooks calls every registered hook with t. Never call it while +// holding boardMu. +func fireBoardHooks(t Task) { + boardHookMu.RLock() + if len(boardHooks) == 0 { + boardHookMu.RUnlock() + return + } + hooks := make([]BoardHook, 0, len(boardHooks)) + for _, h := range boardHooks { + hooks = append(hooks, h) + } + boardHookMu.RUnlock() + for _, h := range hooks { + h(t) + } +} + // UpdateTask replaces a task by ID. func (b *Board) UpdateTask(updated Task) { updated.Normalize() updated.UpdatedAt = time.Now().Format(time.RFC3339) boardMu.Lock() - defer boardMu.Unlock() + replaced := false for i, t := range b.Tasks { if t.ID == updated.ID { b.Tasks[i] = updated - return + replaced = true + break } } - b.Tasks = append(b.Tasks, updated) + if !replaced { + b.Tasks = append(b.Tasks, updated) + } + boardMu.Unlock() + fireBoardHooks(updated) } // RemoveTask deletes a task by ID. func (b *Board) RemoveTask(id string) bool { boardMu.Lock() - defer boardMu.Unlock() + var removed *Task for i, t := range b.Tasks { if t.ID == id { + cp := t + removed = &cp b.Tasks = append(b.Tasks[:i], b.Tasks[i+1:]...) - return true + break } } - return false + boardMu.Unlock() + if removed == nil { + return false + } + // A removed task is reported with an empty column so a consumer can drop + // it from its view. + removed.Column = "" + removed.Status = "" + fireBoardHooks(*removed) + return true } // NextID generates a unique task id. @@ -197,19 +263,24 @@ func (b *Board) nextIDLocked() string { // Returns the stored task. func (b *Board) AddTask(t Task) Task { boardMu.Lock() - defer boardMu.Unlock() if strings.TrimSpace(t.ID) == "" { t.ID = b.nextIDLocked() } t.Normalize() t.UpdatedAt = time.Now().Format(time.RFC3339) + replaced := false for i := range b.Tasks { if b.Tasks[i].ID == t.ID { b.Tasks[i] = t - return t + replaced = true + break } } - b.Tasks = append(b.Tasks, t) + if !replaced { + b.Tasks = append(b.Tasks, t) + } + boardMu.Unlock() + fireBoardHooks(t) return t } diff --git a/pkg/readiness/readiness.go b/pkg/readiness/readiness.go index b2b6169..4833e29 100644 --- a/pkg/readiness/readiness.go +++ b/pkg/readiness/readiness.go @@ -428,12 +428,13 @@ func fixPatchIf(cond bool, kv ...interface{}) map[string]interface{} { // what the config asked for. // // It used to say "Document and ReAct compaction are enabled" whenever -// context_compact and react_compact were both on. Only one of the two ReAct -// call sites exists: the resume path compacts a restored checkpoint, and -// nothing compacts a live 16-iteration worker mid-call (loop.CompactLiveMessages -// has no caller — see loop.LiveReactCompactionWired). An operator reading a -// green "Context Compaction" check concluded a long agent call could not -// exhaust the window, which is the one thing this check did not cover. +// context_compact and react_compact were both on, at a time when only the +// resume path compacted anything: an operator reading a green "Context +// Compaction" check concluded a long agent call could not exhaust the window, +// which was the one thing the check did not cover. The message now comes from +// loop.ReactCompactionStatus, which reads loop.LiveReactCompactionWired — the +// constant of record for whether live requests are compacted (deterministic +// elision of old tool results) — so the claim and the wiring cannot drift. func compactionCheck(cfg *config.Config) Check { on := cfg.ContextCompact && cfg.ReactCompact msg := boolMessage(on, diff --git a/pkg/server/review_api.go b/pkg/server/review_api.go index f28320a..a97acdc 100644 --- a/pkg/server/review_api.go +++ b/pkg/server/review_api.go @@ -26,10 +26,17 @@ import ( // PendingChange is one queued file change with both sides of the diff. type PendingChange struct { - ID string `json:"id"` - Path string `json:"path"` - Kind string `json:"kind"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Path string `json:"path"` + Kind string `json:"kind"` + CreatedAt string `json:"created_at"` + // From is the source path of a ws_mv proposal; empty for other kinds. + From string `json:"from,omitempty"` + // TaskID / Agent / QueryID attribute the proposal to the board task, the + // role and the query that produced it, when the tool call carried them. + TaskID string `json:"task_id,omitempty"` + Agent string `json:"agent,omitempty"` + QueryID string `json:"query_id,omitempty"` Exists bool `json:"exists"` IsNew bool `json:"is_new"` Before string `json:"before"` @@ -41,10 +48,34 @@ type PendingChange struct { Error string `json:"error,omitempty"` } +// pendingFile is the on-disk queue entry. path/kind/content come from +// permissions.RecordPending; the rest is stamped by pkg/workspace at record +// time (workspace.PendingStamp) and is absent from older entries. type pendingFile struct { Path string `json:"path"` Kind string `json:"kind"` Content string `json:"content"` + From string `json:"from,omitempty"` + TaskID string `json:"task_id,omitempty"` + Agent string `json:"agent,omitempty"` + QueryID string `json:"query_id,omitempty"` +} + +// Queue kinds. write/edit/patch all mean "the file's next content is +// Content"; delete and mv are the two that must NOT be applied as a write — +// doing so truncated a "deleted" file to zero bytes and left a moved file's +// source in place. shell entries were never files at all (an older workspace +// mirrored every shell ask into the queue); they are hidden and never applied. +const ( + pendingKindDelete = "delete" + pendingKindMove = "mv" + pendingKindShell = "shell" +) + +// pendingKindApplicable reports whether a queue kind is something apply can +// act on. +func pendingKindApplicable(kind string) bool { + return kind != pendingKindShell } func (s *Server) pendingDir() string { @@ -98,12 +129,16 @@ func (s *Server) readPending(id string, withHunks bool, context int) (PendingCha } ch := PendingChange{ - ID: id, - Path: filepath.ToSlash(pf.Path), - Kind: pf.Kind, - After: pf.Content, - Bytes: len(pf.Content), - Exists: true, + ID: id, + Path: filepath.ToSlash(pf.Path), + Kind: pf.Kind, + From: filepath.ToSlash(pf.From), + TaskID: pf.TaskID, + Agent: pf.Agent, + QueryID: pf.QueryID, + After: pf.Content, + Bytes: len(pf.Content), + Exists: true, } if info, err := os.Stat(full); err == nil { ch.CreatedAt = info.ModTime().UTC().Format(time.RFC3339) @@ -111,6 +146,25 @@ func (s *Server) readPending(id string, withHunks bool, context int) (PendingCha if ts := leadingNanos(id); ts > 0 { ch.CreatedAt = time.Unix(0, ts).UTC().Format(time.RFC3339) } + if !pendingKindApplicable(pf.Kind) { + ch.Error = "not a file change (kind " + pf.Kind + ") — reject it" + ch.After = "" + ch.Bytes = 0 + return ch, nil + } + if pf.Kind == pendingKindMove { + if strings.TrimSpace(pf.From) == "" { + ch.Error = "move entry has no source path" + return ch, nil + } + if herr := s.pendingTargetAllowed(pf.From); herr != nil { + ch.Error = "source: " + herr.Error() + ch.Exists = false + ch.After = "" + ch.Bytes = 0 + return ch, nil + } + } // A queue entry is a FILE in `.slmcode/pending/`, which means a cloned // repository can ship one — nothing here proves the harness wrote it. An @@ -141,6 +195,25 @@ func (s *Server) readPending(id string, withHunks bool, context int) (PendingCha } else { ch.Before = string(before) } + switch pf.Kind { + case pendingKindDelete: + // The diff of a delete is "everything removed"; the recorded content + // is empty by construction. + ch.After = "" + ch.Bytes = 0 + ch.IsNew = false + case pendingKindMove: + // Rendered at the DESTINATION path: before is what the source holds + // now (the destination normally does not exist yet), after is the + // content that will land there. + if src, err := s.workspacePath(pf.From); err == nil { + if data, rerr := os.ReadFile(src); rerr == nil { + ch.Before = string(data) + } else if os.IsNotExist(rerr) && ch.Before == "" { + ch.Error = "source " + filepath.ToSlash(pf.From) + " no longer exists; the recorded content will be written instead" + } + } + } d := ComputeDiff(ch.Before, ch.After, context) ch.Stat = d.Stat @@ -199,6 +272,10 @@ func (s *Server) handleReviewPending(w http.ResponseWriter, r *http.Request) { if err != nil { continue } + if !pendingKindApplicable(ch.Kind) { + // Stale shell mirrors from an older workspace: not a change. + continue + } added += ch.Stat.Added removed += ch.Stat.Removed items = append(items, ch) @@ -244,7 +321,20 @@ func (s *Server) reviewTargets(r *http.Request) ([]string, error) { _ = json.NewDecoder(r.Body).Decode(&req) } if req.All { - return s.listPendingIDs() + ids, err := s.listPendingIDs() + if err != nil { + return nil, err + } + // "all" means every CHANGE; stale shell mirrors are hidden from the + // listing and are skipped here for the same reason. + out := ids[:0] + for _, id := range ids { + if ch, err := s.readPending(id, false, 0); err == nil && !pendingKindApplicable(ch.Kind) { + continue + } + out = append(out, id) + } + return out, nil } ids := append([]string(nil), req.IDs...) if strings.TrimSpace(req.ID) != "" { @@ -282,6 +372,7 @@ func (s *Server) handleReviewApply(w http.ResponseWriter, r *http.Request) { Message: fmt.Sprintf("applied %d pending change(s) from Studio", len(applied)), Output: strings.Join(applied, "\n"), Time: time.Now(), }) + s.emitReviewPending() } writeJSON(w, map[string]any{ "ok": len(failed) == 0, "applied": applied, "failed": failed, @@ -316,6 +407,7 @@ func (s *Server) handleReviewReject(w http.ResponseWriter, r *http.Request) { Message: fmt.Sprintf("rejected %d pending change(s) from Studio", len(rejected)), Time: time.Now(), }) + s.emitReviewPending() } writeJSON(w, map[string]any{ "ok": len(failed) == 0, "rejected": rejected, "failed": failed, @@ -355,13 +447,43 @@ func (s *Server) applyPending(id string) (string, error) { if herr := s.pendingTargetAllowed(pf.Path); herr != nil { return pf.Path, herr } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state - return pf.Path, err - } - // target is a project source file (re-validated above to stay inside the - // workspace) — conventional perms, not secret state. - if err := os.WriteFile(target, []byte(pf.Content), 0o644); err != nil { //nolint:gosec // project source file, conventional perms - return pf.Path, err + // The KIND decides what "apply" means. Every kind used to be applied as + // "write Content to Path": a delete entry (Content "") truncated the file + // instead of removing it, a move entry copied the destination and left the + // source behind, and a shell entry wrote shell.sh at the project root. + switch pf.Kind { + case pendingKindShell: + return pf.Path, errors.New("shell approvals are not file changes and cannot be applied; reject the entry") + case pendingKindDelete: + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return pf.Path, err + } + case pendingKindMove: + if strings.TrimSpace(pf.From) == "" { + return pf.Path, errors.New("move entry has no source path") + } + if herr := s.pendingTargetAllowed(pf.From); herr != nil { + return pf.Path, fmt.Errorf("source: %w", herr) + } + src, err := s.workspacePath(pf.From) + if err != nil { + return pf.Path, ErrPathEscape + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state + return pf.Path, err + } + if err := movePendingFile(src, target, pf.Content); err != nil { + return pf.Path, err + } + default: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state + return pf.Path, err + } + // target is a project source file (re-validated above to stay inside + // the workspace) — conventional perms, not secret state. + if err := os.WriteFile(target, []byte(pf.Content), 0o644); err != nil { //nolint:gosec // project source file, conventional perms + return pf.Path, err + } } if err := os.Remove(full); err != nil && !os.IsNotExist(err) { return pf.Path, err @@ -369,9 +491,73 @@ func (s *Server) applyPending(id string) (string, error) { return filepath.ToSlash(pf.Path), nil } +// movePendingFile realizes a queued ws_mv: rename when the source still +// exists, else write the recorded content (the source was moved or deleted by +// something else in the meantime, and the proposal's intent is the content at +// the destination). +func movePendingFile(src, dst, content string) error { + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("destination %s already exists", filepath.Base(dst)) + } + if _, err := os.Lstat(src); err != nil { + if !os.IsNotExist(err) { + return err + } + return os.WriteFile(dst, []byte(content), 0o644) //nolint:gosec // project source file, conventional perms + } + if err := os.Rename(src, dst); err == nil { + return nil + } + // Cross-device or otherwise un-renamable: copy, verify, then remove. + data, err := os.ReadFile(src) //nolint:gosec // src re-validated against the workspace root by the caller + if err != nil { + return err + } + if err := os.WriteFile(dst, data, 0o644); err != nil { //nolint:gosec // project source file, conventional perms + return err + } + if st, err := os.Stat(dst); err != nil || st.Size() != int64(len(data)) { + _ = os.Remove(dst) + return errors.New("copy of the source was incomplete; source left in place") + } + return os.Remove(src) +} + +// pendingCount is the number of applicable queue entries (shell mirrors from +// an older workspace are not counted; the listing hides them too). func (s *Server) pendingCount() int { ids, _ := s.listPendingIDs() - return len(ids) + n := 0 + for _, id := range ids { + full, err := s.pendingIDPath(id) + if err != nil { + continue + } + raw, err := os.ReadFile(full) //nolint:gosec // queue file under the harness state dir + if err != nil { + continue + } + var pf pendingFile + if json.Unmarshal(raw, &pf) == nil && !pendingKindApplicable(pf.Kind) { + continue + } + n++ + } + return n +} + +// emitReviewPending publishes the queue depth as a `review_pending` event +// ({"pending": N}) so Studio's badge follows the queue without polling. It is +// called after every apply/reject and, through the workspace hook installed in +// NewWithOptions, after every proposal a running agent records. +func (s *Server) emitReviewPending() { + n := s.pendingCount() + s.emit(orchestrator.Event{ + Phase: "review", Kind: "review_pending", Level: "info", + Message: fmt.Sprintf("%d pending review change(s)", n), + Data: map[string]any{"pending": n}, + Time: time.Now(), + }) } func boolParam(r *http.Request, key string, def bool) bool { diff --git a/pkg/server/review_kinds_test.go b/pkg/server/review_kinds_test.go new file mode 100644 index 0000000..27d0d51 --- /dev/null +++ b/pkg/server/review_kinds_test.go @@ -0,0 +1,210 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeQueueEntry writes a raw review-queue entry, as pkg/workspace records +// (and stamps) it, with a controllable timestamp prefix for ordering. +func writeQueueEntry(t *testing.T, slmDir string, nanos int64, obj map[string]any) string { + t.Helper() + dir := filepath.Join(slmDir, "pending") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(obj) + if err != nil { + t.Fatal(err) + } + kind, _ := obj["kind"].(string) + name := strings.ReplaceAll(time.Unix(0, nanos).UTC().Format("20060102150405.000000000"), ".", "") + id := name + "_" + kind + "_x.patch.json" + if err := os.WriteFile(filepath.Join(dir, id), raw, 0o644); err != nil { + t.Fatal(err) + } + return id +} + +func applyAll(t *testing.T, s *Server) (applied []string, failed []reviewActionFailure) { + t.Helper() + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodPost, "/api/review/apply", strings.NewReader(`{"all":true}`))) + if rec.Code != 200 { + t.Fatalf("apply status=%d body=%s", rec.Code, rec.Body.String()) + } + var out struct { + Applied []string `json:"applied"` + Failed []reviewActionFailure `json:"failed"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out.Applied, out.Failed +} + +func lastEventOfKind(s *Server, kind string) (map[string]any, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for i := len(s.events) - 1; i >= 0; i-- { + if s.events[i].Event.Kind == kind { + data, _ := s.events[i].Event.Data.(map[string]any) + return data, true + } + } + return nil, false +} + +// A "delete" entry must remove the file — applying it as a write truncated +// the file to zero bytes and reported success. +func TestReviewApplyDeleteRemovesFile(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + root := h.Config.Root + mustWrite(t, filepath.Join(root, "gone.go"), "package a\n") + writeQueueEntry(t, h.Config.SlmDir(), 1, map[string]any{"path": "gone.go", "kind": "delete", "content": ""}) + + // The listing renders it as everything-removed, not as a 0-byte write. + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/review/pending", nil)) + var listing struct { + Items []PendingChange `json:"items"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &listing) + if len(listing.Items) != 1 || listing.Items[0].Kind != "delete" || listing.Items[0].IsNew || listing.Items[0].Stat.Removed != 1 { + t.Fatalf("listing: %+v", listing.Items) + } + + applied, failed := applyAll(t, s) + if len(failed) != 0 || len(applied) != 1 { + t.Fatalf("applied=%v failed=%+v", applied, failed) + } + if _, err := os.Stat(filepath.Join(root, "gone.go")); !os.IsNotExist(err) { + t.Fatalf("file not removed (err=%v)", err) + } +} + +// An "mv" entry moves the source; applying it as a write copied the content +// to the destination and left the source in place. +func TestReviewApplyMoveMovesSource(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + root := h.Config.Root + mustWrite(t, filepath.Join(root, "old", "a.go"), "package a\n") + writeQueueEntry(t, h.Config.SlmDir(), 1, map[string]any{ + "path": "new/a.go", "kind": "mv", "content": "package a\n", "from": "old/a.go", + "task_id": "T2", "agent": "worker", "query_id": "run-7", + }) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/review/pending", nil)) + var listing struct { + Items []PendingChange `json:"items"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &listing) + if len(listing.Items) != 1 { + t.Fatalf("listing: %+v", listing.Items) + } + it := listing.Items[0] + if it.From != "old/a.go" || it.TaskID != "T2" || it.Agent != "worker" || it.QueryID != "run-7" { + t.Fatalf("stamps not exposed: %+v", it) + } + if it.Before != "package a\n" || it.Stat.Added != 0 || it.Stat.Removed != 0 { + t.Fatalf("move should diff against its source (unchanged content): %+v", it) + } + + applied, failed := applyAll(t, s) + if len(failed) != 0 || len(applied) != 1 { + t.Fatalf("applied=%v failed=%+v", applied, failed) + } + if _, err := os.Stat(filepath.Join(root, "old", "a.go")); !os.IsNotExist(err) { + t.Fatalf("source left behind (err=%v)", err) + } + got, err := os.ReadFile(filepath.Join(root, "new", "a.go")) + if err != nil || string(got) != "package a\n" { + t.Fatalf("destination: %q err=%v", got, err) + } + // A move whose source escapes the workspace is refused, not performed. + writeQueueEntry(t, h.Config.SlmDir(), 2, map[string]any{ + "path": "stolen.txt", "kind": "mv", "content": "x", "from": "../../etc/hostname", + }) + _, failed = applyAll(t, s) + if len(failed) != 1 { + t.Fatalf("escaping move not refused: %+v", failed) + } +} + +// A stale "shell" mirror is never a file: hidden from the listing, skipped by +// {all:true}, refused by id. +func TestReviewShellEntriesAreNeverApplied(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + root := h.Config.Root + id := writeQueueEntry(t, h.Config.SlmDir(), 1, map[string]any{"path": "shell.sh", "kind": "shell", "content": "rm -rf /"}) + writeQueueEntry(t, h.Config.SlmDir(), 2, map[string]any{"path": "b.go", "kind": "write", "content": "package b\n"}) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/review/pending", nil)) + var listing struct { + Count int `json:"count"` + Items []PendingChange `json:"items"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &listing) + if listing.Count != 1 || listing.Items[0].Path != "b.go" { + t.Fatalf("shell entry visible in listing: %+v", listing.Items) + } + + applied, failed := applyAll(t, s) + if len(applied) != 1 || len(failed) != 0 { + t.Fatalf("all: applied=%v failed=%+v", applied, failed) + } + if _, err := os.Stat(filepath.Join(root, "shell.sh")); err == nil { + t.Fatal("shell.sh was written to the project root") + } + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodPost, "/api/review/apply", strings.NewReader(`{"id":"`+id+`"}`))) + var out struct { + OK bool `json:"ok"` + Failed []reviewActionFailure `json:"failed"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if out.OK || len(out.Failed) != 1 || !strings.Contains(out.Failed[0].Error, "shell") { + t.Fatalf("shell entry applied by id: %s", rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "shell.sh")); err == nil { + t.Fatal("shell.sh was written to the project root by id") + } +} + +// Apply and reject both publish the queue depth as a review_pending event. +func TestReviewPendingEventOnApplyAndReject(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + writeQueueEntry(t, h.Config.SlmDir(), 1, map[string]any{"path": "a.go", "kind": "write", "content": "package a\n"}) + id2 := writeQueueEntry(t, h.Config.SlmDir(), 2, map[string]any{"path": "b.go", "kind": "write", "content": "package b\n"}) + writeQueueEntry(t, h.Config.SlmDir(), 3, map[string]any{"path": "c.go", "kind": "write", "content": "package c\n"}) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodPost, "/api/review/reject", strings.NewReader(`{"id":"`+id2+`"}`))) + data, ok := lastEventOfKind(s, "review_pending") + if !ok || data["pending"] != 2 { + t.Fatalf("after reject: event=%v ok=%v", data, ok) + } + applyAll(t, s) + data, ok = lastEventOfKind(s, "review_pending") + if !ok || data["pending"] != 0 { + t.Fatalf("after apply: event=%v ok=%v", data, ok) + } + // The event is in the replay buffer like any other. + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/runs/latest", nil)) + if !strings.Contains(rec.Body.String(), `"kind": "review_pending"`) { + t.Fatalf("review_pending not replayed: %s", rec.Body.String()) + } +} diff --git a/pkg/server/run_lifecycle_test.go b/pkg/server/run_lifecycle_test.go new file mode 100644 index 0000000..8a7de77 --- /dev/null +++ b/pkg/server/run_lifecycle_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/hitl" + "github.com/UnicoLab/slmcode/pkg/orchestrator" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/workspace" +) + +// blockingRun is a fake engine: it blocks until its context is canceled or +// release is closed, then returns the given result. +func blockingRun(release <-chan struct{}, res *orchestrator.Result) func(context.Context, string) (*orchestrator.Result, error) { + return func(ctx context.Context, _ string) (*orchestrator.Result, error) { + select { + case <-ctx.Done(): + case <-release: + } + return res, nil + } +} + +func latest(t *testing.T, s *Server) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/runs/latest", nil)) + if rec.Code != 200 { + t.Fatalf("latest status=%d body=%s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out +} + +func startRun(t *testing.T, s *Server) int { + t.Helper() + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodPost, "/api/runs", strings.NewReader(`{"query":"do a thing"}`))) + return rec.Code +} + +func waitFor(t *testing.T, what string, cond func() bool) { + 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", what) +} + +// Stop → Start used to race: handleStopRun cleared running while the old run +// goroutine was still unwinding, so a following POST /api/runs started a new +// run, and the OLD goroutine then cleared the new run's running flag, +// restored its options and published its own result over the new run's. +func TestStopThenStartDoesNotLeakOldRunState(t *testing.T) { + t.Setenv("SLMCODE_NO_CALIBRATE", "1") // the fake engine must not wait behind a model probe + h := newHarness(t) + s := New(h, nil) + defer func() { _ = s.Shutdown(context.Background()) }() + + // Run 1 blocks until stopped. The fake never observes the orchestrator's + // own cancel, so a stop that reaches it proves the per-run context works. + s.runFn = blockingRun(nil, &orchestrator.Result{Summary: "first"}) + if code := startRun(t, s); code != 200 { + t.Fatalf("start #1 status=%d", code) + } + if l := latest(t, s); l["running"] != true || l["stopping"] != false { + t.Fatalf("after start: %v", l) + } + + // While run 1 is up, starting again is a conflict. + if code := startRun(t, s); code != http.StatusConflict { + t.Fatalf("concurrent start status=%d, want 409", code) + } + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodPost, "/api/runs/stop", nil)) + if rec.Code != 200 { + t.Fatalf("stop status=%d", rec.Code) + } + // running stays true until the goroutine exits; the fake returns as soon + // as its context is canceled, so this resolves quickly. + waitFor(t, "run 1 to unwind", func() bool { + s.mu.Lock() + defer s.mu.Unlock() + return !s.running + }) + if l := latest(t, s); l["running"] != false || l["stopping"] != false { + t.Fatalf("after stop unwound: %v", l) + } + res1, _ := latest(t, s)["result"].(map[string]any) + if res1 == nil || res1["summary"] != "first" { + t.Fatalf("run 1 result not published: %v", res1) + } + + // Run 2 blocks on its own release channel and must own the state. + release2 := make(chan struct{}) + s.runFn = blockingRun(release2, &orchestrator.Result{Summary: "second"}) + if code := startRun(t, s); code != 200 { + t.Fatalf("start #2 status=%d", code) + } + l := latest(t, s) + if l["running"] != true { + t.Fatalf("run 2 not running: %v", l) + } + // The snapshot is scoped to run 2: exactly one run_start, no run_end yet. + starts, ends := 0, 0 + for _, ev := range l["events"].([]any) { + switch ev.(map[string]any)["kind"] { + case "run_start": + starts++ + case "run_end", "run_stop": + ends++ + } + } + if starts != 1 || ends != 0 { + t.Fatalf("snapshot not scoped to the current run: starts=%d ends=%d", starts, ends) + } + + // A goroutine from a STALE generation finishing late must not touch the + // live run: running stays true, the result is not replaced, run_end is + // not emitted. + before := len(latest(t, s)["events"].([]any)) + s.finishRun(1, &orchestrator.Result{Summary: "stale"}, nil, savedRunOptions{}, "finished") + l = latest(t, s) + if l["running"] != true { + t.Fatal("stale finishRun cleared running of the live run") + } + if res, _ := l["result"].(map[string]any); res != nil && res["summary"] == "stale" { + t.Fatal("stale finishRun published its result over the live run") + } + if len(l["events"].([]any)) != before { + t.Fatal("stale finishRun emitted run_end into the live run's stream") + } + + close(release2) + waitFor(t, "run 2 to finish", func() bool { + s.mu.Lock() + defer s.mu.Unlock() + return !s.running + }) + res2, _ := latest(t, s)["result"].(map[string]any) + if res2 == nil || res2["summary"] != "second" { + t.Fatalf("run 2 result: %v", res2) + } +} + +// A second click on Run while a run is in flight is a 409 — and must not +// sweep the in-flight run's pending asks. hitl.ClearAll used to run BEFORE +// the running check. +func TestStartConflictKeepsPendingAsks(t *testing.T) { + t.Setenv("SLMCODE_NO_CALIBRATE", "1") // the fake engine must not wait behind a model probe + h := newHarness(t) + s := New(h, nil) + defer func() { _ = s.Shutdown(context.Background()) }() + slm := h.Config.SlmDir() + + release := make(chan struct{}) + defer close(release) + s.runFn = blockingRun(release, nil) + if code := startRun(t, s); code != 200 { + t.Fatalf("start status=%d", code) + } + ask := workspace.ShellAsk{ID: "shell-7", Kind: "shell", Command: "go test ./...", TimeoutS: 120, + CreatedAt: time.Now().UTC().Format(time.RFC3339)} + if err := hitl.WriteAskID(slm, "shell", ask.ID, ask); err != nil { + t.Fatal(err) + } + planAsk := plan.BuildPlanApproveAsk("ship it", &plan.Board{Tasks: []plan.Task{{ID: "T1", Title: "main"}}}) + if err := hitl.WriteAsk(slm, "plan", planAsk); err != nil { + t.Fatal(err) + } + + if code := startRun(t, s); code != http.StatusConflict { + t.Fatalf("conflicting start status=%d, want 409", code) + } + if ids, _ := hitl.ListAskIDs(slm, "shell"); len(ids) != 1 { + t.Fatalf("shell ask swept by the refused start: %v", ids) + } + if ok, _ := hitl.ReadAsk(slm, "plan", &plan.PlanApproveAsk{}); !ok { + t.Fatal("plan ask swept by the refused start") + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, newAPIRequest(http.MethodGet, "/api/shell/pending", nil)) + var pending struct { + Pending bool `json:"pending"` + Ask workspace.ShellAsk `json:"ask"` + Asks []workspace.ShellAsk `json:"asks"` + Count int `json:"count"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &pending) + if !pending.Pending || pending.Ask.ID != "shell-7" || pending.Count != 1 || len(pending.Asks) != 1 { + t.Fatalf("shell pending shape: %s", rec.Body.String()) + } +} + +// blockingWriter stalls on the first Write until released, standing in for a +// browser that opened GET /api/runs/latest and stopped reading. +type blockingWriter struct { + hdr http.Header + release chan struct{} + once sync.Once + started chan struct{} +} + +func (w *blockingWriter) Header() http.Header { return w.hdr } +func (w *blockingWriter) WriteHeader(int) {} +func (w *blockingWriter) Write(p []byte) (int, error) { + w.once.Do(func() { close(w.started) }) + <-w.release + return len(p), nil +} + +// handleLatestRun used to encode while holding s.mu; the orchestrator emits +// synchronously into Server.emit, which takes s.mu, so a stalled fetch blocked +// a worker mid tool-call. +func TestLatestRunDoesNotBlockEmit(t *testing.T) { + t.Setenv("SLMCODE_NO_CALIBRATE", "1") // the fake engine must not wait behind a model probe + h := newHarness(t) + s := New(h, nil) + defer func() { _ = s.Shutdown(context.Background()) }() + for i := 0; i < 50; i++ { + s.emit(orchestrator.Event{Phase: "execute", Kind: "output", Message: strings.Repeat("x", 512), Time: time.Now()}) + } + bw := &blockingWriter{hdr: http.Header{}, release: make(chan struct{}), started: make(chan struct{})} + done := make(chan struct{}) + go func() { + s.handleLatestRun(bw, newAPIRequest(http.MethodGet, "/api/runs/latest", nil)) + close(done) + }() + select { + case <-bw.started: + case <-time.After(5 * time.Second): + t.Fatal("handler never started writing") + } + emitted := make(chan struct{}) + go func() { + s.emit(orchestrator.Event{Phase: "execute", Kind: "output", Message: "while stalled", Time: time.Now()}) + close(emitted) + }() + select { + case <-emitted: + case <-time.After(2 * time.Second): + t.Fatal("emit blocked behind a stalled /api/runs/latest writer") + } + close(bw.release) + <-done +} diff --git a/pkg/server/server.go b/pkg/server/server.go index c141bf8..d409538 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -1,6 +1,8 @@ package server import ( + "bufio" + "bytes" "context" "encoding/json" "errors" @@ -77,8 +79,45 @@ type Server struct { seq uint64 lastRes *orchestrator.Result running bool - subs map[*subscriber]struct{} - closed bool + // stopping is set by POST /api/runs/stop while the run goroutine is still + // unwinding. running stays true until that goroutine exits, so a new run + // cannot start on top of the old one's teardown. + stopping bool + // runGen counts run starts. The run goroutine captures the generation it + // was started under and only clears running / restores per-run options + // when it still matches, so a goroutine from a stopped run that finishes + // late cannot clobber the state of the run that replaced it. + runGen uint64 + // runStartSeq is the sequence number of the current run's first event. + // /api/runs/latest and the current-run activity view scope their snapshot + // to it; the ring itself is never cleared, so SSE replay by Last-Event-ID + // keeps working across a stop → start. + runStartSeq uint64 + // runCancel cancels the current run's context (POST /api/runs/stop). + runCancel context.CancelFunc + // lastPhase is the most recent phase seen on a structural event; the + // task_update events synthesized from the board hook carry it because the + // board itself does not know which phase moved the task. + lastPhase string + subs map[*subscriber]struct{} + closed bool + // hookRemovers undo the package-level change hooks installed by + // installChangeHooks; run in Shutdown. + hookRemovers []func() + // activityCache memoizes past runs' derived team timelines by event-log + // identity (see team_activity.go); activityOrder is insertion order for + // eviction. + activityMu sync.Mutex + activityCache map[string]*activityCacheEntry + activityOrder []string + // usageCache memoizes per-query token/cost totals for GET /api/queries. + usageMu sync.Mutex + usageCache map[string]usageCacheEntry + // runFn / resumeFn are the engine entry points POST /api/runs and + // /api/runs/resume drive. They default to the harness and are swappable so + // lifecycle tests can run a controllable fake instead of a model. + runFn func(ctx context.Context, query string) (*orchestrator.Result, error) + resumeFn func(ctx context.Context, id string) (*orchestrator.Result, error) // cfgMu guards mutation of the shared *config.Config and of the // Orchestrator pointer. Handlers must go through cfg()/orch()/ @@ -131,10 +170,30 @@ func NewWithOptions(h *harness.Harness, ui fs.FS, opts Options) *Server { baseCancel: cancel, } s.wireOrchestratorEvents() + s.installChangeHooks() s.routes() return s } +// installChangeHooks subscribes the server to the two package-level change +// feeds it turns into SSE events: review-queue proposals recorded by a running +// agent's workspace (review_pending) and board task changes (task_update). +// Both hooks are removed again in Shutdown so a test that builds several +// servers does not leak emitters between them. +func (s *Server) installChangeHooks() { + s.hookRemovers = append(s.hookRemovers, + workspace.AddPendingHook(func(_, _, _ string) { s.emitReviewPending() }), + plan.AddBoardHook(func(t plan.Task) { s.emitTaskUpdate(t) }), + ) +} + +func (s *Server) removeChangeHooks() { + for _, rm := range s.hookRemovers { + rm() + } + s.hookRemovers = nil +} + // wireOrchestratorEvents keeps Studio SSE subscribed across config rebuilds. func (s *Server) wireOrchestratorEvents() { orch := s.orch() @@ -146,12 +205,55 @@ func (s *Server) wireOrchestratorEvents() { }) } +// taskUpdateKind is the SSE kind for a board task change; reviewPendingKind +// announces the review-queue depth. Both are synthesized by the server from +// the package-level hooks, not emitted by the engine. +const ( + taskUpdateKind = "task_update" + reviewPendingKind = "review_pending" +) + +// emitTaskUpdate publishes one task change as a task_update event: +// Phase = the current run phase when one is known, else "board"; TaskID = the +// task id; Message = " -> "; Data = {"task": }. A removed task arrives with an empty column. +func (s *Server) emitTaskUpdate(t plan.Task) { + s.mu.Lock() + phase := s.lastPhase + s.mu.Unlock() + if phase == "" { + phase = "board" + } + col := t.Column + if col == "" { + col = "removed" + } + s.emit(orchestrator.Event{ + Phase: phase, Kind: taskUpdateKind, Level: "info", TaskID: t.ID, + Message: t.ID + " -> " + col, + Data: map[string]any{"task": t}, + Time: time.Now(), + }) +} + func (s *Server) emit(e orchestrator.Event) { s.mu.Lock() if s.closed { s.mu.Unlock() return } + // Track the run's current phase for the synthesized task_update events. + // Only engine phases count: the synthesized kinds and the review + // endpoints' own "review" phase would otherwise mislabel later updates. + switch e.Kind { + case taskUpdateKind, reviewPendingKind, tokenKind: + case "run_end", "run_stop": + s.lastPhase = "" + default: + if e.Phase != "" && e.Phase != "review" { + s.lastPhase = e.Phase + } + } s.seq++ se := seqEvent{Seq: s.seq, Event: e} s.events = append(s.events, se) @@ -247,6 +349,7 @@ func (s *Server) Shutdown(ctx context.Context) error { case <-time.After(shutdownRunGrace): } + s.removeChangeHooks() s.mu.Lock() s.closed = true subs := make([]*subscriber, 0, len(s.subs)) @@ -379,6 +482,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/tasks", s.handleAddTask) s.mux.HandleFunc("PATCH /api/tasks/{id}", s.handlePatchTask) s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask) + s.mux.HandleFunc("POST /api/tasks/{id}/retry", s.handleRetryTask) s.mux.HandleFunc("GET /api/board", s.handleGetBoard) s.mux.HandleFunc("GET /api/columns", s.handleColumns) s.mux.HandleFunc("GET /api/skills", s.handleSkills) @@ -807,6 +911,56 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"ok": "true"}) } +// errNoBoard is the 409 of POST /api/tasks/{id}/retry when nothing is loaded. +var errNoBoard = errors.New("no board loaded") + +// handleRetryTask — POST /api/tasks/{id}/retry +// +// Puts a task back in front of the workers: column ready_to_dev, the ladder +// retry counter reset, the error cleared, and "retried from Studio" appended +// to attempt_log so the next attempt is told this is a retry. The board is +// persisted through the live store and the change is published as a +// task_update event through the board hook. 409 when no board is loaded, 404 +// for an unknown id. Responds with the task as GET /api/tasks renders it. +func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + o := s.orch() + if o == nil || o.Board() == nil { + http.Error(w, errNoBoard.Error(), http.StatusConflict) + return + } + store := o.Board() + _ = store.Load() + if snap := store.Snapshot(); len(snap.Tasks) == 0 { + http.Error(w, errNoBoard.Error(), http.StatusConflict) + return + } + var out plan.Task + err := store.Update(func(b *plan.Board) error { + t, ok := b.Get(id) + if !ok { + return os.ErrNotExist + } + t.MoveTo(plan.ColReadyToDev) + t.Retries = 0 + t.Error = "" + t.AttemptLog = append(t.AttemptLog, "retried from Studio") + t.Normalize() + b.UpdateTask(t) + out = t + return nil + }) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "task not found: "+id, http.StatusNotFound) + return + } + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, out) +} + func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { list, err := s.orch().Skills().List() if err != nil { @@ -902,16 +1056,15 @@ func (s *Server) handleStartRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "query required", 400) return } - hitl.ClearAll(s.slmDir()) - s.mu.Lock() - if s.running { - s.mu.Unlock() + gen, ctx, ok := s.beginRun() + if !ok { http.Error(w, "run already in progress", http.StatusConflict) return } - s.running = true - s.events = nil - s.mu.Unlock() + // Pending asks belong to the previous run and are swept only once THIS + // run owns the slot. Sweeping before the running check meant a second + // click on Run — a 409 — deleted the in-flight run's open shell ask. + hitl.ClearAll(s.slmDir()) // Per-run engine/specialist/skill selection. These belong on the Run call // (see runOptions / "wiring required"), but until orchestrator.Run accepts @@ -929,8 +1082,6 @@ func (s *Server) handleStartRun(w http.ResponseWriter, r *http.Request) { s.runWG.Add(1) go func() { defer s.runWG.Done() - defer s.restoreRunOptions(saved) - ctx := s.runContext() // Calibrate FIRST, because the model may have changed since the server // started. Studio is where models get switched, and a switch that lands // between launch and the first run would otherwise be governed by the @@ -942,25 +1093,80 @@ func (s *Server) handleStartRun(w http.ResponseWriter, r *http.Request) { // cold model can take a minute to measure, and that minute belongs in // the event stream where the user can watch it, not in a hung request. s.ensureCalibrated(ctx) - res, err := s.h.Run(ctx, query) - s.mu.Lock() + res, err := s.run(ctx, query) + s.finishRun(gen, res, err, saved, "finished") + }() + + writeJSON(w, map[string]string{"status": "started", "query": req.Query}) +} + +// run / resume are the engine entry points, overridable for tests. +func (s *Server) run(ctx context.Context, query string) (*orchestrator.Result, error) { + if s.runFn != nil { + return s.runFn(ctx, query) + } + return s.h.Run(ctx, query) +} + +func (s *Server) resume(ctx context.Context, id string) (*orchestrator.Result, error) { + if s.resumeFn != nil { + return s.resumeFn(ctx, id) + } + return s.h.Resume(ctx, id) +} + +// beginRun claims the run slot. It returns the run's generation and context, +// or ok=false when a run is in progress (including one that is still +// unwinding after a stop). The event ring is NOT cleared: subscribers are +// mid-stream on it, and /api/runs/latest scopes its snapshot by runStartSeq +// instead. +func (s *Server) beginRun() (gen uint64, ctx context.Context, ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return 0, nil, false + } + s.running = true + s.stopping = false + s.runGen++ + s.runStartSeq = s.seq + 1 + s.lastPhase = "" + ctx, s.runCancel = context.WithCancel(s.runContext()) + return s.runGen, ctx, true +} + +// finishRun is the tail of every run goroutine. Only the goroutine whose +// generation is still current may clear running, publish the result, restore +// the per-run options and emit run_end: a goroutine from a stopped run that +// unwinds late would otherwise clobber the run that replaced it. +func (s *Server) finishRun(gen uint64, res *orchestrator.Result, err error, saved savedRunOptions, doneMsg string) { + s.mu.Lock() + current := s.runGen == gen + if current { s.running = false + s.stopping = false + if s.runCancel != nil { + s.runCancel() + s.runCancel = nil + } if res != nil { s.lastRes = res } - s.mu.Unlock() - phase := "done" - msg := "finished" - if err != nil { - phase = "error" - msg = err.Error() - } else if res != nil { - msg = res.Summary - } - s.emit(orchestrator.Event{Phase: phase, Kind: "run_end", Message: msg, Time: time.Now()}) - }() - - writeJSON(w, map[string]string{"status": "started", "query": req.Query}) + } + s.mu.Unlock() + if !current { + return + } + s.restoreRunOptions(saved) + phase := "done" + msg := doneMsg + if err != nil { + phase = "error" + msg = err.Error() + } else if res != nil { + msg = res.Summary + } + s.emit(orchestrator.Event{Phase: phase, Kind: "run_end", Message: msg, Time: time.Now()}) } // runOptions carries the per-run overrides Studio sends with POST /api/runs. @@ -1095,15 +1301,14 @@ func (s *Server) handleResumeRun(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&req) } id := strings.TrimSpace(req.ID) - s.mu.Lock() - if s.running { - s.mu.Unlock() + // ctx derives from runContext(), NOT context.Background(): a resumed run + // used to be invisible to Shutdown, so Ctrl-C returned to the shell while + // the agent kept writing files. + gen, ctx, ok := s.beginRun() + if !ok { http.Error(w, "run already in progress", http.StatusConflict) return } - s.running = true - s.events = nil - s.mu.Unlock() s.wireOrchestratorEvents() s.emit(orchestrator.Event{ @@ -1112,26 +1317,8 @@ func (s *Server) handleResumeRun(w http.ResponseWriter, r *http.Request) { s.runWG.Add(1) go func() { defer s.runWG.Done() - // runContext(), NOT context.Background(): a resumed run used to be - // invisible to Shutdown, so Ctrl-C returned to the shell while the - // agent kept writing files. - ctx := s.runContext() - res, err := s.h.Resume(ctx, id) - s.mu.Lock() - s.running = false - if res != nil { - s.lastRes = res - } - s.mu.Unlock() - phase := "done" - msg := "resumed" - if err != nil { - phase = "error" - msg = err.Error() - } else if res != nil { - msg = res.Summary - } - s.emit(orchestrator.Event{Phase: phase, Kind: "run_end", Message: msg, Time: time.Now()}) + res, err := s.resume(ctx, id) + s.finishRun(gen, res, err, savedRunOptions{}, "resumed") }() writeJSON(w, map[string]string{"status": "started", "id": id}) @@ -1442,57 +1629,104 @@ func (s *Server) handleEscalateAnswer(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true, "action": ans.Action}) } +// pendingShellAsks lists the shell asks that are still waiting for a decision, +// oldest first. Expired asks are withdrawn on the way; answered ones are left +// for their worker to collect and skipped. +func (s *Server) pendingShellAsks() ([]workspace.ShellAsk, error) { + ids, err := hitl.ListAskIDs(s.slmDir(), "shell") + if err != nil { + return nil, err + } + fallback := s.cfg().ShellAskTimeout + var out []workspace.ShellAsk + for _, id := range ids { + var ask workspace.ShellAsk + ok, err := hitl.ReadAskID(s.slmDir(), "shell", id, &ask) + if err != nil || !ok { + continue + } + if ask.ID == "" { + ask.ID = id + } + askPath := hitl.AskIDPath(s.slmDir(), "shell", id) + if askExpiredWithFallback(ask.CreatedAt, ask.TimeoutS, fallback, askPath) { + hitl.ClearID(s.slmDir(), "shell", id) + continue + } + if answered, expired := answeredAskState(hitl.AnswerIDPath(s.slmDir(), "shell", id), ask.ID, ask.CreatedAt, ask.TimeoutS, fallback, askPath); answered || expired { + if expired { + hitl.ClearID(s.slmDir(), "shell", id) + } + continue + } + out = append(out, ask) + } + return out, nil +} + +// handleShellPending — GET /api/shell/pending +// +// Shell asks are per-ask files now (a parallel wave raises several at once), +// so the response carries the whole list in `asks`. The single-ask shape the +// UI was built on is kept: `pending` and `ask` describe the OLDEST open ask. +// +// {"pending": true, "ask": {…first…}, "asks": [{…}, {…}], "count": 2} +// {"pending": false, "asks": [], "count": 0} func (s *Server) handleShellPending(w http.ResponseWriter, r *http.Request) { - var ask workspace.ShellAsk - ok, err := hitl.ReadAsk(s.slmDir(), "shell", &ask) + asks, err := s.pendingShellAsks() if err != nil { http.Error(w, err.Error(), 500) return } - if !ok { - writeJSON(w, map[string]any{"pending": false}) - return - } - if answered, expired := answeredAskState(hitl.AnswersPath(s.slmDir(), "shell"), ask.ID, ask.CreatedAt, ask.TimeoutS, s.cfg().ShellAskTimeout, hitl.AskPath(s.slmDir(), "shell")); answered || expired { - if expired { - hitl.Clear(s.slmDir(), "shell") - writeJSON(w, map[string]any{"pending": false, "expired": true}) - return - } - writeJSON(w, map[string]any{"pending": false, "answered": true}) - return - } - if askExpiredWithFallback(ask.CreatedAt, ask.TimeoutS, s.cfg().ShellAskTimeout, hitl.AskPath(s.slmDir(), "shell")) { - hitl.Clear(s.slmDir(), "shell") - writeJSON(w, map[string]any{"pending": false, "expired": true}) + if len(asks) == 0 { + writeJSON(w, map[string]any{"pending": false, "asks": []workspace.ShellAsk{}, "count": 0}) return } - writeJSON(w, map[string]any{"pending": true, "ask": ask}) + writeJSON(w, map[string]any{"pending": true, "ask": asks[0], "asks": asks, "count": len(asks)}) } +// handleShellApprove — POST /api/shell/approve {ask_id, decision} +// +// ask_id selects which of the pending asks is being decided. It may be omitted +// only when exactly one ask is pending; with several, an unspecified or +// unknown id is a 409 so a stale card can never approve a newer command. func (s *Server) handleShellApprove(w http.ResponseWriter, r *http.Request) { var ans workspace.ShellAnswer if err := json.NewDecoder(r.Body).Decode(&ans); err != nil { http.Error(w, "invalid JSON", 400) return } - var ask workspace.ShellAsk - ok, err := hitl.ReadAsk(s.slmDir(), "shell", &ask) + asks, err := s.pendingShellAsks() if err != nil { http.Error(w, err.Error(), 500) return } - if !ok { + posted := strings.TrimSpace(ans.AskID) + // An id that already has a decision on file is a duplicate click, not a + // missing ask — even once the worker has collected it. + if posted != "" { + if ok, _ := hitl.ReadAnswerID(s.slmDir(), "shell", posted, &workspace.ShellAnswer{}); ok { + http.Error(w, "shell ask already answered", http.StatusConflict) + return + } + } + if len(asks) == 0 { http.Error(w, "no pending shell ask", http.StatusNotFound) return } - if !requireMatchingAskID(w, ans.AskID, ask.ID) { - return + var ask *workspace.ShellAsk + for i := range asks { + if asks[i].ID == posted { + ask = &asks[i] + break + } } - if askExpiredWithFallback(ask.CreatedAt, ask.TimeoutS, s.cfg().ShellAskTimeout, hitl.AskPath(s.slmDir(), "shell")) { - hitl.Clear(s.slmDir(), "shell") - http.Error(w, "shell ask expired", http.StatusGone) - return + if ask == nil { + if posted == "" && len(asks) == 1 { + ask = &asks[0] + } else if !requireMatchingAskID(w, posted, asks[0].ID) { + return + } } ans.Decision = strings.ToLower(strings.TrimSpace(ans.Decision)) if ans.Decision != "approve" && ans.Decision != "deny" { @@ -1503,7 +1737,7 @@ func (s *Server) handleShellApprove(w http.ResponseWriter, r *http.Request) { if ans.AnsweredAt == "" { ans.AnsweredAt = time.Now().UTC().Format(time.RFC3339) } - if err := hitl.WriteAnswersOnce(s.slmDir(), "shell", ans); err != nil { + if err := hitl.WriteAnswerIDOnce(s.slmDir(), "shell", ask.ID, ans); err != nil { if os.IsExist(err) { http.Error(w, "shell ask already answered", http.StatusConflict) return @@ -1659,12 +1893,27 @@ func (s *Server) handleClearFeedback(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } +// handleStopRun — POST /api/runs/stop +// +// Stopping is a REQUEST: the engine is told to unwind and the run's context +// is canceled, but running stays true (with stopping set) until the run +// goroutine actually exits and finishRun clears it. Flipping running to false +// here let a following POST /api/runs start a new run on top of the old one's +// teardown, and the old goroutine then cleared the new run's state. func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) { - s.orch().Stop() + if orch := s.orch(); orch != nil { + orch.Stop() + } s.mu.Lock() - was := s.running - s.running = false + was := s.running && !s.stopping + if s.running { + s.stopping = true + } + cancel := s.runCancel s.mu.Unlock() + if cancel != nil { + cancel() + } if was { s.emit(orchestrator.Event{ Phase: "done", Kind: "run_stop", Message: "run stopped by user", Time: time.Now(), @@ -1673,25 +1922,38 @@ func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "stopping"}) } +// handleLatestRun — GET /api/runs/latest +// +// The snapshot is COPIED under s.mu and encoded after it is released. The +// orchestrator emits synchronously into Server.emit, which takes s.mu, so +// encoding while holding the lock let one stalled browser fetch block a +// worker mid-tool-call. Events are scoped to the current (or most recent) +// run by sequence number; the ring itself is never cleared, so SSE +// subscribers keep their replay window across runs. func (s *Server) handleLatestRun(w http.ResponseWriter, r *http.Request) { s.mu.Lock() - defer s.mu.Unlock() events := make([]orchestrator.Event, 0, len(s.events)) seqs := make([]uint64, 0, len(s.events)) for _, se := range s.events { + if se.Seq < s.runStartSeq { + continue + } events = append(events, se.Event) seqs = append(seqs, se.Seq) } - writeJSON(w, map[string]interface{}{ - "running": s.running, - "result": s.lastRes, - "events": events, + snapshot := map[string]interface{}{ + "running": s.running, + "stopping": s.stopping, + "result": s.lastRes, + "events": events, // event_seqs[i] is the SSE id of events[i]; the client uses the last // one as its Last-Event-ID baseline so a snapshot + stream never // double-renders. "event_seqs": seqs, "last_seq": s.seq, - }) + } + s.mu.Unlock() + writeJSON(w, snapshot) } // handleSSE streams run events. @@ -1860,6 +2122,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() running := s.running + stopping := s.stopping s.mu.Unlock() skillCount := s.skillCount() comp, ok, compErr := composer.LoadDynamic(s.slmDir()) @@ -1877,6 +2140,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "text": st, "running": running, + "stopping": stopping, "readiness": ready, "composition": s.savedCompositionView(compPtr), "composition_error": compErrText, @@ -2448,6 +2712,16 @@ func (s *Server) handleListQueries(w http.ResponseWriter, r *http.Request) { Interrupted bool `json:"interrupted,omitempty"` Phase string `json:"phase,omitempty"` ResumeFrom string `json:"resume_from,omitempty"` + // Cheap per-run figures for the history list: wall time from the + // turn's timestamps, task counts from the stored board, team names + // from the tasks' squads, tokens/cost from a cached scan of the log. + DurationMS int64 `json:"duration_ms"` + Tokens int `json:"tokens"` + CostUSD float64 `json:"cost_usd"` + TasksTotal int `json:"tasks_total"` + TasksDone int `json:"tasks_done"` + FailedTasks int `json:"failed_tasks"` + Teams []string `json:"teams"` } var out []item for _, e := range entries { @@ -2458,11 +2732,35 @@ func (s *Server) handleListQueries(w http.ResponseWriter, r *http.Request) { if err != nil { continue } - out = append(out, item{ + it := item{ ID: t.ID, Query: t.Query, Success: t.Success, Summary: t.Summary, UpdatedAt: t.UpdatedAt, Interrupted: t.Interrupted, Phase: t.Phase, ResumeFrom: t.ResumeFrom, - }) + Teams: []string{}, + } + if created, ok := parseAskCreatedAt(t.CreatedAt); ok { + if updated, ok := parseAskCreatedAt(t.UpdatedAt); ok && updated.After(created) { + it.DurationMS = updated.Sub(created).Milliseconds() + } + } + teams := map[string]bool{} + for _, task := range t.Board.Tasks { + task.Normalize() + it.TasksTotal++ + switch { + case task.Column == plan.ColDone: + it.TasksDone++ + case task.Column == plan.ColBlocked || task.Status == plan.StatusFailed || task.Error != "": + it.FailedTasks++ + } + if sq := strings.TrimSpace(task.Squad); sq != "" && !teams[sq] { + teams[sq] = true + it.Teams = append(it.Teams, sq) + } + } + sort.Strings(it.Teams) + it.Tokens, it.CostUSD = s.queryUsage(t.ID) + out = append(out, it) } sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) if out == nil { @@ -2471,6 +2769,63 @@ func (s *Server) handleListQueries(w http.ResponseWriter, r *http.Request) { writeJSON(w, out) } +// usageCacheEntry memoizes one query's token/cost totals by log identity. +type usageCacheEntry struct { + size int64 + modTime time.Time + tokens int + cost float64 +} + +// queryUsage sums tokens and cost_usd over a query's event log. The scan is +// cached per (id, size, mtime), and only lines that can carry a figure are +// decoded, so listing the history does not re-parse every token delta of +// every past run on each request. +func (s *Server) queryUsage(id string) (int, float64) { + path := session.EventsPath(s.slmDir(), id) + info, err := os.Stat(path) + if err != nil { + return 0, 0 + } + s.usageMu.Lock() + if e, ok := s.usageCache[id]; ok && e.size == info.Size() && e.modTime.Equal(info.ModTime()) { + s.usageMu.Unlock() + return e.tokens, e.cost + } + s.usageMu.Unlock() + + tokens, cost := 0, 0.0 + if f, err := os.Open(path); err == nil { //nolint:gosec // harness state under SlmDir + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if !bytes.Contains(line, []byte(`"tokens"`)) && !bytes.Contains(line, []byte(`"cost_usd"`)) { + continue + } + var rec struct { + Tokens int `json:"tokens"` + CostUSD float64 `json:"cost_usd"` + } + if json.Unmarshal(line, &rec) == nil { + tokens += rec.Tokens + cost += rec.CostUSD + } + } + _ = f.Close() + } + s.usageMu.Lock() + if s.usageCache == nil { + s.usageCache = map[string]usageCacheEntry{} + } + if len(s.usageCache) > 256 { + s.usageCache = map[string]usageCacheEntry{} + } + s.usageCache[id] = usageCacheEntry{size: info.Size(), modTime: info.ModTime(), tokens: tokens, cost: cost} + s.usageMu.Unlock() + return tokens, cost +} + func (s *Server) handleGetQuery(w http.ResponseWriter, r *http.Request) { id := filepath.Base(r.PathValue("id")) if id == "." || id == ".." || id == "" { diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 230a848..7dc8050 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -309,7 +309,7 @@ func TestHITLAnswersRequireCurrentAskIDForAllKinds(t *testing.T) { name: "shell", endpoint: "/api/shell/approve", writeAsk: func(h *harness.Harness, id string) error { - return hitl.WriteAsk(h.Config.SlmDir(), "shell", workspace.ShellAsk{ + return hitl.WriteAskID(h.Config.SlmDir(), "shell", id, workspace.ShellAsk{ ID: id, Kind: "shell", Command: "go test ./...", @@ -456,7 +456,7 @@ func TestShellApproveRejectsInvalidDecision(t *testing.T) { OnTimeout: "deny", CreatedAt: time.Now().UTC().Format(time.RFC3339), } - if err := hitl.WriteAsk(h.Config.SlmDir(), "shell", ask); err != nil { + if err := hitl.WriteAskID(h.Config.SlmDir(), "shell", ask.ID, ask); err != nil { t.Fatal(err) } @@ -472,7 +472,7 @@ func TestShellApproveRejectsInvalidDecision(t *testing.T) { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } } - if ok, err := hitl.ReadAnswers(h.Config.SlmDir(), "shell", &workspace.ShellAnswer{}); err != nil || ok { + if ok, err := hitl.ReadAnswerID(h.Config.SlmDir(), "shell", ask.ID, &workspace.ShellAnswer{}); err != nil || ok { t.Fatalf("invalid decision wrote answer ok=%v err=%v", ok, err) } } diff --git a/pkg/server/studio_api_test.go b/pkg/server/studio_api_test.go new file mode 100644 index 0000000..c16f476 --- /dev/null +++ b/pkg/server/studio_api_test.go @@ -0,0 +1,217 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/orchestrator" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/session" +) + +func apiCall(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + var rd *strings.Reader + if body != "" { + rd = strings.NewReader(body) + } else { + rd = strings.NewReader("") + } + s.Handler().ServeHTTP(rec, newAPIRequest(method, path, rd)) + return rec +} + +// taskUpdateEvents returns every task_update event in the ring, oldest first. +func taskUpdateEvents(s *Server) []orchestrator.Event { + s.mu.Lock() + defer s.mu.Unlock() + var out []orchestrator.Event + for _, se := range s.events { + if se.Event.Kind == "task_update" { + out = append(out, se.Event) + } + } + return out +} + +// B1: every board task change is published as a task_update event carrying +// the full task under data.task, exactly as GET /api/tasks renders it. +func TestBoardChangesEmitTaskUpdateEvents(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + defer func() { _ = s.Shutdown(context.Background()) }() + + if rec := apiCall(t, s, http.MethodPost, "/api/tasks", `{"id":"T1","title":"main","column":"ready_to_dev"}`); rec.Code != 200 { + t.Fatalf("add task: %d %s", rec.Code, rec.Body.String()) + } + if rec := apiCall(t, s, http.MethodPatch, "/api/tasks/T1", `{"column":"in_progress"}`); rec.Code != 200 { + t.Fatalf("patch task: %d %s", rec.Code, rec.Body.String()) + } + evs := taskUpdateEvents(s) + if len(evs) == 0 { + t.Fatal("no task_update event emitted for a board change") + } + ev := evs[len(evs)-1] + if ev.Phase != "board" || ev.TaskID != "T1" || ev.Message != "T1 -> in_progress" { + t.Fatalf("event shape: phase=%q task=%q msg=%q", ev.Phase, ev.TaskID, ev.Message) + } + // Serialize exactly as SSE / /api/runs/latest do and compare with GET /api/tasks. + raw, _ := json.Marshal(ev.Data) + var data struct { + Task plan.Task `json:"task"` + } + if err := json.Unmarshal(raw, &data); err != nil || data.Task.ID != "T1" || data.Task.Column != "in_progress" || data.Task.Status != plan.StatusRunning { + t.Fatalf("data.task: %s (err=%v)", raw, err) + } + var board plan.Board + _ = json.Unmarshal(apiCall(t, s, http.MethodGet, "/api/tasks", "").Body.Bytes(), &board) + if len(board.Tasks) != 1 || board.Tasks[0].Column != data.Task.Column || board.Tasks[0].Title != data.Task.Title { + t.Fatalf("event task differs from GET /api/tasks: %+v vs %+v", data.Task, board.Tasks) + } + // Replayed like any other event. + if !strings.Contains(apiCall(t, s, http.MethodGet, "/api/runs/latest", "").Body.String(), `"kind": "task_update"`) { + t.Fatal("task_update not in the replay buffer") + } + // The phase follows the run once one is known. + s.emit(orchestrator.Event{Phase: "execute", Kind: "phase", Message: "wave 1", Time: time.Now()}) + apiCall(t, s, http.MethodPatch, "/api/tasks/T1", `{"column":"in_review"}`) + evs = taskUpdateEvents(s) + if got := evs[len(evs)-1].Phase; got != "execute" { + t.Fatalf("phase not taken from the run: %q", got) + } + // A removed task is reported with an empty column ("-> removed"). + apiCall(t, s, http.MethodDelete, "/api/tasks/T1", "") + evs = taskUpdateEvents(s) + if last := evs[len(evs)-1]; last.Message != "T1 -> removed" { + t.Fatalf("removal event: %q", last.Message) + } +} + +// B2: POST /api/tasks/{id}/retry. +func TestRetryTaskEndpoint(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + defer func() { _ = s.Shutdown(context.Background()) }() + + // No board loaded → 409. + if rec := apiCall(t, s, http.MethodPost, "/api/tasks/T1/retry", ""); rec.Code != http.StatusConflict { + t.Fatalf("no board: %d %s", rec.Code, rec.Body.String()) + } + board := plan.Board{Tasks: []plan.Task{{ + ID: "T1", Title: "main", Column: plan.ColBlocked, Retries: 3, GateRetries: 1, + Error: "tests failed", AttemptLog: []string{"attempt 1 failed because X"}, + Criteria: []plan.Criterion{{ID: "c1", Text: "builds"}}, + }}} + raw, _ := json.Marshal(board) + if rec := apiCall(t, s, http.MethodPut, "/api/tasks", string(raw)); rec.Code != 200 { + t.Fatalf("put board: %d %s", rec.Code, rec.Body.String()) + } + // Unknown id → 404. + if rec := apiCall(t, s, http.MethodPost, "/api/tasks/T9/retry", ""); rec.Code != http.StatusNotFound { + t.Fatalf("unknown id: %d %s", rec.Code, rec.Body.String()) + } + rec := apiCall(t, s, http.MethodPost, "/api/tasks/T1/retry", "") + if rec.Code != 200 { + t.Fatalf("retry: %d %s", rec.Code, rec.Body.String()) + } + var got plan.Task + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Column != plan.ColReadyToDev || got.Status != plan.StatusReady || got.Retries != 0 || got.Error != "" { + t.Fatalf("task after retry: %+v", got) + } + if len(got.AttemptLog) != 2 || got.AttemptLog[1] != "retried from Studio" { + t.Fatalf("attempt_log: %v", got.AttemptLog) + } + if got.GateRetries != 1 || len(got.Criteria) != 1 { + t.Fatalf("unrelated fields disturbed: %+v", got) + } + // Persisted, and exposed by GET /api/tasks with the fields Studio needs. + body := apiCall(t, s, http.MethodGet, "/api/tasks", "").Body.String() + for _, key := range []string{`"attempt_log"`, `"gate_retries"`, `"criteria"`, `"retried from Studio"`} { + if !strings.Contains(body, key) { + t.Fatalf("GET /api/tasks lacks %s: %s", key, body) + } + } + // And published as a task_update. + evs := taskUpdateEvents(s) + if len(evs) == 0 || evs[len(evs)-1].Message != "T1 -> ready_to_dev" { + t.Fatalf("retry did not emit task_update: %+v", evs) + } +} + +// B3: GET /api/queries carries per-run figures computed from stored state. +func TestListQueriesCarriesRunFigures(t *testing.T) { + h := newHarness(t) + s := New(h, nil) + slm := h.Config.SlmDir() + turn, err := session.BeginTurn(slm, "run-1", "add a thing") + if err != nil { + t.Fatal(err) + } + turn.CreatedAt = time.Now().Add(-90 * time.Second).UTC().Format(time.RFC3339) + board := plan.Board{Tasks: []plan.Task{ + {ID: "T1", Title: "a", Column: plan.ColDone, Squad: "backend"}, + {ID: "T2", Title: "b", Column: plan.ColBlocked, Squad: "frontend"}, + {ID: "T3", Title: "c", Column: plan.ColReadyToDev, Squad: "backend"}, + }} + if err := session.SaveTurnBoard(slm, turn, board); err != nil { + t.Fatal(err) + } + for _, rec := range []session.EventRecord{ + {Phase: "plan", Kind: "usage", Tokens: 1200, CostUSD: 0.002}, + {Phase: "execute", Kind: "token", Message: "delta"}, + {Phase: "execute", Kind: "usage", Tokens: 800, CostUSD: 0.001}, + } { + if err := session.AppendEvent(slm, "run-1", rec); err != nil { + t.Fatal(err) + } + } + session.FlushEventLogs() + + rec := apiCall(t, s, http.MethodGet, "/api/queries", "") + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var items []struct { + ID string `json:"id"` + DurationMS int64 `json:"duration_ms"` + Tokens int `json:"tokens"` + CostUSD float64 `json:"cost_usd"` + TasksTotal int `json:"tasks_total"` + TasksDone int `json:"tasks_done"` + FailedTasks int `json:"failed_tasks"` + Teams []string `json:"teams"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("items: %s", rec.Body.String()) + } + it := items[0] + if it.ID != "run-1" || it.TasksTotal != 3 || it.TasksDone != 1 || it.FailedTasks != 1 { + t.Fatalf("task figures: %+v", it) + } + if it.Tokens != 2000 || it.CostUSD < 0.0029 || it.CostUSD > 0.0031 { + t.Fatalf("usage figures: %+v", it) + } + if it.DurationMS < 80_000 || it.DurationMS > 200_000 { + t.Fatalf("duration_ms: %d", it.DurationMS) + } + if len(it.Teams) != 2 || it.Teams[0] != "backend" || it.Teams[1] != "frontend" { + t.Fatalf("teams: %v", it.Teams) + } + // Second listing is served from the usage cache (same figures). + rec = apiCall(t, s, http.MethodGet, "/api/queries", "") + if !strings.Contains(rec.Body.String(), `"tokens": 2000`) { + t.Fatalf("cached listing: %s", rec.Body.String()) + } +} diff --git a/pkg/server/team_activity.go b/pkg/server/team_activity.go index 821da43..134478c 100644 --- a/pkg/server/team_activity.go +++ b/pkg/server/team_activity.go @@ -2,6 +2,7 @@ package server import ( "net/http" + "os" "regexp" "sort" "strconv" @@ -368,24 +369,36 @@ func (s *Server) handleTeamActivity(w http.ResponseWriter, r *http.Request) { } var sources []activitySource queryID := strings.TrimSpace(r.URL.Query().Get("query")) + var cached *activityCacheEntry if queryID != "" { - // ReadEvents keeps the FIRST n records; a run that streamed tokens - // has tens of thousands, and the gates and late triage that matter - // most sit at the end. Read the whole log. - recs, err := session.ReadEvents(s.slmDir(), queryID, pastRunEventCap) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - for _, rec := range recs { - sources = append(sources, activitySource{ - Time: rec.Time, Phase: rec.Phase, Kind: rec.Kind, Agent: rec.Agent, - TaskID: rec.TaskID, Message: rec.Message, - }) + // A past run's log is immutable once the run ended, and Studio + // re-requests this view on every Teams-page visit. The derived + // timeline is cached per (query, size, mtime) of the log so the + // second visit is a map lookup, not a re-parse of a 250k-line file. + if entry, ok := s.cachedActivity(queryID); ok { + cached = entry + } else { + // ReadEvents keeps the FIRST n records; a run that streamed tokens + // has tens of thousands, and the gates and late triage that matter + // most sit at the end. Read the whole log. + recs, err := session.ReadEvents(s.slmDir(), queryID, pastRunEventCap) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, rec := range recs { + sources = append(sources, activitySource{ + Time: rec.Time, Phase: rec.Phase, Kind: rec.Kind, Agent: rec.Agent, + TaskID: rec.TaskID, Message: rec.Message, + }) + } } } else { s.mu.Lock() for _, se := range s.events { + if se.Seq < s.runStartSeq { + continue // previous run; the ring is no longer cleared per run + } sources = append(sources, activitySource{ Time: se.Event.Time.Format(time.RFC3339Nano), Phase: se.Event.Phase, Kind: se.Event.Kind, Level: se.Event.Level, Agent: se.Event.Agent, TaskID: se.Event.TaskID, Message: se.Event.Message, @@ -411,7 +424,15 @@ func (s *Server) handleTeamActivity(w http.ResponseWriter, r *http.Request) { } } - entries := deriveTeamActivity(sources, planPtr, taskTeams) + var entries []ActivityEntry + if cached != nil { + entries = cached.entries + } else { + entries = deriveTeamActivity(sources, planPtr, taskTeams) + if queryID != "" { + s.storeActivity(queryID, entries) + } + } if len(entries) > limit { entries = entries[len(entries)-limit:] } @@ -442,6 +463,54 @@ func (s *Server) handleTeamActivity(w http.ResponseWriter, r *http.Request) { // pastRunEventCap bounds a past run's event log read for the timeline. const pastRunEventCap = 250000 +// activityCacheEntry is one derived timeline keyed by the event log's identity. +type activityCacheEntry struct { + size int64 + modTime time.Time + entries []ActivityEntry +} + +// maxActivityCache bounds the per-query cache; past this the oldest entry by +// insertion is dropped (a re-derive, exactly today's cost). +const maxActivityCache = 32 + +// cachedActivity returns the derived timeline for queryID when the event log +// still has the size and mtime it was derived from. +func (s *Server) cachedActivity(queryID string) (*activityCacheEntry, bool) { + info, err := os.Stat(session.EventsPath(s.slmDir(), queryID)) //nolint:gosec // session.TurnDir sanitizes the id; the path cannot leave the queries dir + if err != nil { + return nil, false + } + s.activityMu.Lock() + defer s.activityMu.Unlock() + e, ok := s.activityCache[queryID] + if !ok || e.size != info.Size() || !e.modTime.Equal(info.ModTime()) { + return nil, false + } + return e, true +} + +func (s *Server) storeActivity(queryID string, entries []ActivityEntry) { + info, err := os.Stat(session.EventsPath(s.slmDir(), queryID)) //nolint:gosec // session.TurnDir sanitizes the id; the path cannot leave the queries dir + if err != nil { + return + } + s.activityMu.Lock() + defer s.activityMu.Unlock() + if s.activityCache == nil { + s.activityCache = map[string]*activityCacheEntry{} + s.activityOrder = nil + } + if _, exists := s.activityCache[queryID]; !exists { + s.activityOrder = append(s.activityOrder, queryID) + for len(s.activityOrder) > maxActivityCache { + delete(s.activityCache, s.activityOrder[0]) + s.activityOrder = s.activityOrder[1:] + } + } + s.activityCache[queryID] = &activityCacheEntry{size: info.Size(), modTime: info.ModTime(), entries: entries} +} + // planBelongsToBoard says whether the saved org chart is the one this board // was built under. // diff --git a/pkg/session/events.go b/pkg/session/events.go index 3eb05da..e1e7e6b 100644 --- a/pkg/session/events.go +++ b/pkg/session/events.go @@ -1,7 +1,9 @@ package session import ( + "bufio" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -26,18 +28,154 @@ type EventRecord struct { Model string `json:"model,omitempty"` } -var eventMu sync.Mutex - // EventsPath returns .slmcode/queries//events.jsonl func EventsPath(slmDir, runID string) string { return filepath.Join(TurnDir(slmDir, runID), "events.jsonl") } +// ── Buffered per-run appender ───────────────────────────────────────────── +// +// AppendEvent used to open, write, fsync-maybe and close the log file for +// EVERY event under one global mutex — token deltas included, which arrive +// at decode speed and are by far the most numerous kind. A streaming worker +// therefore paid an open/close pair per token, serialized against every other +// worker's events. The log is now written through one open file per run with +// a bufio.Writer: +// +// - token deltas are not persisted at all (they are a live-view concern; the +// usage event that follows carries the numbers that matter afterwards); +// - structural kinds flush immediately, so a crash loses at most the chatty +// tail; run_end / run_stop flush, fsync and close; +// - a timer flushes the chatty kinds within eventFlushInterval regardless; +// - the turn's end (WriteTurnSummary) closes the appender; an appender idle +// for eventIdleClose is closed by the timer so a run that never reports an +// end does not pin a descriptor forever. +// +// ReadEvents flushes the run's appender first, so a reader (Studio replaying +// the current run) never observes a torn tail. + +const ( + eventFlushInterval = 500 * time.Millisecond + eventIdleClose = 30 * time.Second + eventBufferBytes = 64 * 1024 +) + +// eventLogSkipKinds are never persisted to the JSONL log. +var eventLogSkipKinds = map[string]bool{"token": true, "delta": true} + +// eventChattyKinds are buffered and flushed by the timer; everything else is +// structural and flushed on write. +var eventChattyKinds = map[string]bool{ + "output": true, "tool_call": true, "tool_result": true, "tool": true, + "usage": true, "calibration": true, "progress": true, +} + +type eventAppender struct { + mu sync.Mutex + f *os.File + w *bufio.Writer + timer *time.Timer + lastUsed time.Time + closed bool +} + +var ( + appendersMu sync.Mutex + appenders = map[string]*eventAppender{} +) + +func appenderKey(slmDir, runID string) string { return slmDir + "\x00" + runID } + +// appenderFor returns the open appender for the run, creating it on first use. +func appenderFor(slmDir, runID string) (*eventAppender, error) { + key := appenderKey(slmDir, runID) + appendersMu.Lock() + defer appendersMu.Unlock() + if a, ok := appenders[key]; ok && !a.closed { + return a, nil + } + dir := TurnDir(slmDir, runID) + // Session state is local to the invoking user; keep it out of reach of + // other accounts on shared machines. + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, err + } + f, err := os.OpenFile(EventsPath(slmDir, runID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + a := &eventAppender{f: f, w: bufio.NewWriterSize(f, eventBufferBytes), lastUsed: time.Now()} + a.timer = time.AfterFunc(eventFlushInterval, func() { a.tick(key) }) + appenders[key] = a + return a, nil +} + +// tick is the timer callback: flush what is buffered, close when idle, +// otherwise re-arm. +func (a *eventAppender) tick(key string) { + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return + } + _ = a.w.Flush() + idle := time.Since(a.lastUsed) > eventIdleClose + if idle { + a.closeLocked(false) + a.mu.Unlock() + appendersMu.Lock() + if cur, ok := appenders[key]; ok && cur == a { + delete(appenders, key) + } + appendersMu.Unlock() + return + } + a.timer.Reset(eventFlushInterval) + a.mu.Unlock() +} + +func (a *eventAppender) write(line []byte, structural bool) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return os.ErrClosed + } + a.lastUsed = time.Now() + if _, err := a.w.Write(line); err != nil { + return err + } + if structural { + return a.w.Flush() + } + return nil +} + +func (a *eventAppender) flushLocked() error { return a.w.Flush() } + +// closeLocked flushes, optionally fsyncs, and closes the file. +func (a *eventAppender) closeLocked(sync bool) { + if a.closed { + return + } + _ = a.w.Flush() + if sync { + _ = a.f.Sync() + } + _ = a.f.Close() + if a.timer != nil { + a.timer.Stop() + } + a.closed = true +} + // AppendEvent appends one JSON line to the turn event log (best-effort). func AppendEvent(slmDir, runID string, rec EventRecord) error { if slmDir == "" || runID == "" { return nil } + if eventLogSkipKinds[rec.Kind] { + return nil + } if rec.Time == "" { rec.Time = time.Now().Format(time.RFC3339Nano) } @@ -47,41 +185,93 @@ func AppendEvent(slmDir, runID string, rec EventRecord) error { if err != nil { return err } - eventMu.Lock() - defer eventMu.Unlock() - dir := TurnDir(slmDir, runID) - // Session state is local to the invoking user; keep it out of reach of - // other accounts on shared machines. - if err := os.MkdirAll(dir, 0o750); err != nil { - return err - } - f, err := os.OpenFile(EventsPath(slmDir, runID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + a, err := appenderFor(slmDir, runID) if err != nil { return err } - // A close error here can't be surfaced to the caller once the write - // below has already reported success/failure; append is best-effort. - defer func() { _ = f.Close() }() - if _, err := f.Write(append(b, '\n')); err != nil { - return err + structural := !eventChattyKinds[rec.Kind] + if err := a.write(append(b, '\n'), structural); err != nil { + if errors.Is(err, os.ErrClosed) { + // Closed by the idle timer between lookup and write: reopen once. + if a, err = appenderFor(slmDir, runID); err == nil { + err = a.write(append(b, '\n'), structural) + } + } + if err != nil { + return err + } } if shouldSyncEvent(rec) { - return f.Sync() + CloseEventLog(slmDir, runID) } return nil } +// shouldSyncEvent names the kinds after which the log must be durable and +// the appender can be released: the run is over (or paused for a human). func shouldSyncEvent(rec EventRecord) bool { switch rec.Kind { - case "ask_answered", "run_start", "run_end", "run_stop": + case "run_end", "run_stop": return true default: return false } } -// ReadEvents loads all event records (capped). +// FlushEventLog flushes the run's buffered events to the OS (no fsync). +func FlushEventLog(slmDir, runID string) { + appendersMu.Lock() + a, ok := appenders[appenderKey(slmDir, runID)] + appendersMu.Unlock() + if !ok { + return + } + a.mu.Lock() + if !a.closed { + _ = a.flushLocked() + } + a.mu.Unlock() +} + +// FlushEventLogs flushes every open appender. +func FlushEventLogs() { + appendersMu.Lock() + all := make([]*eventAppender, 0, len(appenders)) + for _, a := range appenders { + all = append(all, a) + } + appendersMu.Unlock() + for _, a := range all { + a.mu.Lock() + if !a.closed { + _ = a.flushLocked() + } + a.mu.Unlock() + } +} + +// CloseEventLog flushes, fsyncs and closes the run's appender. Called at the +// end of a turn; a later AppendEvent for the same run simply reopens. +func CloseEventLog(slmDir, runID string) { + key := appenderKey(slmDir, runID) + appendersMu.Lock() + a, ok := appenders[key] + if ok { + delete(appenders, key) + } + appendersMu.Unlock() + if !ok { + return + } + a.mu.Lock() + a.closeLocked(true) + a.mu.Unlock() +} + +// ReadEvents loads all event records (capped). The run's appender is flushed +// first so a live run's tail is visible. func ReadEvents(slmDir, runID string, limit int) ([]EventRecord, error) { + FlushEventLog(slmDir, runID) b, err := os.ReadFile(EventsPath(slmDir, runID)) if err != nil { if os.IsNotExist(err) { diff --git a/pkg/session/events_appender_test.go b/pkg/session/events_appender_test.go new file mode 100644 index 0000000..e0c7ad1 --- /dev/null +++ b/pkg/session/events_appender_test.go @@ -0,0 +1,87 @@ +package session + +import ( + "os" + "strings" + "testing" +) + +// The event log is written through one buffered appender per run: token +// deltas are not persisted, chatty kinds are buffered until a structural kind +// or the timer flushes them, and run_end closes the file. +func TestEventAppenderBuffersAndSkipsTokens(t *testing.T) { + slm := t.TempDir() + const run = "run-buf" + for _, rec := range []EventRecord{ + {Phase: "execute", Kind: "output", Message: "chatty one"}, + {Phase: "execute", Kind: "token", Message: "d"}, + {Phase: "execute", Kind: "token", Message: "e"}, + {Phase: "execute", Kind: "output", Message: "chatty two"}, + } { + if err := AppendEvent(slm, run, rec); err != nil { + t.Fatal(err) + } + } + // Chatty kinds sit in the buffer; a reader through ReadEvents sees them + // because it flushes first, and no token delta was persisted. + recs, err := ReadEvents(slm, run, 0) + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 || recs[0].Message != "chatty one" || recs[1].Message != "chatty two" { + t.Fatalf("records after flush: %+v", recs) + } + raw, _ := os.ReadFile(EventsPath(slm, run)) + if strings.Contains(string(raw), `"token"`) { + t.Fatalf("token deltas were persisted: %s", raw) + } + + // A structural kind flushes on write: the file grows without ReadEvents. + before := len(raw) + if err := AppendEvent(slm, run, EventRecord{Phase: "execute", Kind: "phase", Message: "wave 1"}); err != nil { + t.Fatal(err) + } + raw, _ = os.ReadFile(EventsPath(slm, run)) + if len(raw) <= before || !strings.Contains(string(raw), "wave 1") { + t.Fatalf("structural kind not flushed immediately: %s", raw) + } + appendersMu.Lock() + _, open := appenders[appenderKey(slm, run)] + appendersMu.Unlock() + if !open { + t.Fatal("appender should stay open between structural events") + } + + // run_end flushes, syncs and closes the appender. + if err := AppendEvent(slm, run, EventRecord{Phase: "done", Kind: "run_end", Message: "finished"}); err != nil { + t.Fatal(err) + } + appendersMu.Lock() + _, open = appenders[appenderKey(slm, run)] + appendersMu.Unlock() + if open { + t.Fatal("run_end did not close the appender") + } + recs, _ = ReadEvents(slm, run, 0) + if len(recs) != 4 || recs[3].Kind != "run_end" { + t.Fatalf("records after run_end: %+v", recs) + } + // A later append for the same run reopens transparently. + if err := AppendEvent(slm, run, EventRecord{Phase: "done", Kind: "note", Message: "late"}); err != nil { + t.Fatal(err) + } + CloseEventLog(slm, run) + recs, _ = ReadEvents(slm, run, 0) + if len(recs) != 5 || recs[4].Message != "late" { + t.Fatalf("records after reopen: %+v", recs) + } +} + +func BenchmarkAppendEventChatty(b *testing.B) { + slm := b.TempDir() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = AppendEvent(slm, "bench", EventRecord{Phase: "execute", Kind: "output", Message: "tool output line"}) + } + CloseEventLog(slm, "bench") +} diff --git a/pkg/session/turn.go b/pkg/session/turn.go index f9acd3d..778ba63 100644 --- a/pkg/session/turn.go +++ b/pkg/session/turn.go @@ -125,6 +125,8 @@ func WriteTurnSummary(slmDir string, t *Turn, board plan.Board, extraNotes strin return "", err } _ = appendSummariesIndex(slmDir, t, body) + // The turn is over: make its event log durable and release the appender. + CloseEventLog(slmDir, t.ID) return path, nil } diff --git a/pkg/squads/board.go b/pkg/squads/board.go index bf330e4..a60c04e 100644 --- a/pkg/squads/board.go +++ b/pkg/squads/board.go @@ -221,12 +221,48 @@ func ForeignPatterns(p *Plan, wave []plan.Task) []string { return out } -// BriefFor returns the squad brief for a task, or "" when it has no squad. +// BriefFor returns the brief a task's worker is handed: its own squad's brief +// when it has a squad, the seam brief when it has none because its files +// belong to two or more squads, and "" for an unassigned task that touches no +// owned territory. +// +// The seam case used to return "". A task the router left unassigned BECAUSE +// it straddles the contract — an API client on the frontend side, a handler +// on the backend side, one task — is the task that most needs the contract, +// and it was the one task that got no word of it. The worker on the seam was +// then the only agent on the run building without the interface spec. func BriefFor(p *Plan, t plan.Task) string { - if p == nil || t.Squad == "" { + if p == nil { + return "" + } + if t.Squad != "" { + return p.Brief(t.Squad) + } + owners := ownersOf(p, t.Files) + if len(owners) < 2 { return "" } - return p.Brief(t.Squad) + return p.SeamBrief(owners) +} + +// ownersOf lists the squads whose territory the files fall in, in plan order. +func ownersOf(p *Plan, files []string) []string { + if p == nil { + return nil + } + seen := map[string]bool{} + for _, f := range files { + if owner, ok := p.Owner(f); ok { + seen[owner] = true + } + } + var out []string + for _, s := range p.Squads { + if seen[s.ID] { + out = append(out, s.ID) + } + } + return out } // ── Contract enforcement ───────────────────────────────────────────────── diff --git a/pkg/squads/render.go b/pkg/squads/render.go index 0e40be6..5a8cf8d 100644 --- a/pkg/squads/render.go +++ b/pkg/squads/render.go @@ -136,6 +136,64 @@ func (p *Plan) Brief(squadID string) string { return b.String() } +// Bounds on the seam brief. It is pasted into a worker prompt on a 30B-class +// model, where attention is the scarce resource, so it carries the contract +// and nothing else: no charters, no rosters, a clipped spec per interface and +// a cap on how many interfaces are spelled out. +const ( + maxSeamInterfaces = 12 + maxSeamSpecChars = 320 +) + +// SeamBrief is the brief for a task that sits on the contract itself: its +// files belong to two or more of the named squads, so no single squad's brief +// applies and the whole interface list does. Interfaces only — every ID, its +// provider and consumers, and a clipped spec — with no squad charter, because +// the worker is on neither team and needs the seam, not the missions. +func (p *Plan) SeamBrief(owners []string) string { + if p == nil || len(owners) < 2 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "## Seam task — this work spans squads %s\n\n", strings.Join(owners, " and ")) + b.WriteString("It is on no single team because its files fall in more than one team's territory. " + + "Build BOTH sides to the frozen contract below; do not redesign the interface from either side.\n") + for _, id := range owners { + if s, ok := p.Squad(id); ok { + fmt.Fprintf(&b, "- `%s` owns %s\n", s.ID, joinCode(s.Owns)) + } + } + if len(p.Contract.Interfaces) == 0 { + b.WriteString("\nThe plan froze no interfaces; keep the two sides agreeing on the shapes you introduce.\n") + return b.String() + } + b.WriteString("\n### The frozen contract (interfaces only)\n\n") + for i, in := range p.Contract.Interfaces { + if i >= maxSeamInterfaces { + fmt.Fprintf(&b, "- … and %d more in `%s`\n", len(p.Contract.Interfaces)-i, ContractFile) + break + } + fmt.Fprintf(&b, "- **%s**", in.ID) + if in.Provider != "" { + fmt.Fprintf(&b, " (provided by `%s`", in.Provider) + if len(in.Consumers) > 0 { + fmt.Fprintf(&b, ", consumed by `%s`", strings.Join(in.Consumers, "`, `")) + } + b.WriteString(")") + } + if spec := strings.Join(strings.Fields(in.Spec), " "); spec != "" { + if len(spec) > maxSeamSpecChars { + spec = spec[:maxSeamSpecChars] + "…" + } + b.WriteString(" — " + spec) + } + b.WriteString("\n") + } + fmt.Fprintf(&b, "\nThe full contract is in `%s`. It is frozen: if it is wrong, report that "+ + "in your output rather than diverging from it.\n", ContractFile) + return b.String() +} + func writeInterface(b *strings.Builder, in Interface) { fmt.Fprintf(b, "- **%s**", in.ID) if in.Spec != "" { diff --git a/pkg/squads/seam_test.go b/pkg/squads/seam_test.go new file mode 100644 index 0000000..67fc0c8 --- /dev/null +++ b/pkg/squads/seam_test.go @@ -0,0 +1,95 @@ +package squads + +import ( + "strings" + "testing" + + "github.com/UnicoLab/slmcode/pkg/plan" +) + +func seamPlan() *Plan { + return &Plan{ + Squads: []Squad{ + {ID: "backend", Name: "Backend", Charter: "BACKEND CHARTER: own the API", Owns: []string{"cmd/**", "internal/**"}}, + {ID: "frontend", Name: "Frontend", Charter: "FRONTEND CHARTER: own the SPA", Owns: []string{"web/**"}}, + }, + Contract: Contract{Interfaces: []Interface{ + {ID: "GET /api/todos", Provider: "backend", Consumers: []string{"frontend"}, Spec: "200 [{id,title,done}]"}, + {ID: "POST /api/todos", Provider: "backend", Consumers: []string{"frontend"}, Spec: "body {title} -> 201 {id}"}, + {ID: "VITE_API_BASE", Provider: "frontend", Consumers: []string{"backend"}, Spec: "env var the SPA reads"}, + }}, + } +} + +// A task the router left unassigned BECAUSE it straddles the seam is the task +// that most needs the contract. It gets the whole interface list — every ID — +// and none of the squad charters. +func TestSeamTaskGetsTheWholeContract(t *testing.T) { + p := seamPlan() + straddling := plan.Task{ID: "T3", Files: []string{"internal/http/todos.go", "web/src/api.ts"}} + + brief := BriefFor(p, straddling) + if brief == "" { + t.Fatal("a task spanning two squads' territory got no brief at all") + } + for _, in := range p.Contract.Interfaces { + if !strings.Contains(brief, in.ID) { + t.Errorf("seam brief omits interface %q:\n%s", in.ID, brief) + } + } + if strings.Contains(brief, "CHARTER") { + t.Errorf("seam brief carries a squad charter — interfaces only:\n%s", brief) + } + if !strings.Contains(brief, "backend and frontend") { + t.Errorf("seam brief must name the squads it spans:\n%s", brief) + } + if !strings.Contains(brief, "provided by `backend`") || !strings.Contains(brief, "consumed by `frontend`") { + t.Errorf("seam brief must say which side owes each interface:\n%s", brief) + } +} + +// The other cases are unchanged: an assigned task gets its own squad's brief, +// and an unassigned task inside one territory (or none) gets nothing. +func TestBriefForKeepsTheOtherCases(t *testing.T) { + p := seamPlan() + if got := BriefFor(p, plan.Task{Squad: "backend", Files: []string{"cmd/main.go"}}); !strings.Contains(got, "BACKEND CHARTER") { + t.Fatalf("an assigned task must get its squad's brief:\n%s", got) + } + if got := BriefFor(p, plan.Task{Files: []string{"web/src/App.tsx"}}); got != "" { + t.Fatalf("an unassigned task inside one territory got a brief:\n%s", got) + } + if got := BriefFor(p, plan.Task{Files: []string{"README.md"}}); got != "" { + t.Fatalf("an unassigned task outside every territory got a brief:\n%s", got) + } + if got := BriefFor(nil, plan.Task{Files: []string{"cmd/a.go", "web/b.ts"}}); got != "" { + t.Fatal("nil plan must yield no brief") + } +} + +// The seam brief is pasted into a small model's prompt: the interface list is +// capped and each spec clipped. +func TestSeamBriefIsBounded(t *testing.T) { + p := seamPlan() + p.Contract.Interfaces = nil + long := strings.Repeat("shape ", 200) + for i := 0; i < maxSeamInterfaces+5; i++ { + p.Contract.Interfaces = append(p.Contract.Interfaces, Interface{ + ID: "IFACE-" + string(rune('A'+i)), Provider: "backend", Consumers: []string{"frontend"}, Spec: long, + }) + } + brief := p.SeamBrief([]string{"backend", "frontend"}) + if !strings.Contains(brief, "and 5 more") { + t.Fatalf("the interface list was not capped:\n%s", brief) + } + if strings.Contains(brief, long) { + t.Fatal("a long spec was pasted whole") + } + if len(brief) > (maxSeamInterfaces+1)*(maxSeamSpecChars+120) { + t.Fatalf("seam brief is %d bytes — not bounded", len(brief)) + } + // A plan with no contract still names the seam and says there is none. + p.Contract.Interfaces = nil + if got := p.SeamBrief([]string{"backend", "frontend"}); !strings.Contains(got, "froze no interfaces") { + t.Fatalf("a contract-less seam brief must say so:\n%s", got) + } +} diff --git a/pkg/workspace/adversarial_jail_test.go b/pkg/workspace/adversarial_jail_test.go index fbf01ab..8c83be9 100644 --- a/pkg/workspace/adversarial_jail_test.go +++ b/pkg/workspace/adversarial_jail_test.go @@ -116,6 +116,53 @@ func TestJailEscapes(t *testing.T) { if _, err := ws.resolve("etcx/passwd"); err == nil { t.Errorf("agent-created symlink escape not blocked") } + + // 10. DANGLING symlink: the target does not exist, so EvalSymlinks fails — + // the check used to fall back to the lexical verdict and os.WriteFile then + // followed the link and CREATED the outside file. + t.Run("dangling_symlink", func(t *testing.T) { + victim := filepath.Join(outside, "victim.txt") + _ = os.Remove(victim) + _ = os.Symlink(victim, filepath.Join(root, "dangling.txt")) + _ = os.WriteFile(filepath.Join(root, "src.txt"), []byte("move me"), 0o644) + ws.markRead("src.txt") + + s, err := advCall(t, ws.writeFile, map[string]interface{}{"path": "dangling.txt", "content": "PWNED"}) + if err == nil && !strings.Contains(s, "escape") && !strings.Contains(s, "refus") { + t.Errorf("ws_write through dangling symlink not refused: %q", s) + } + if _, serr := os.Stat(victim); serr == nil { + t.Fatalf("ws_write created %s outside the root", victim) + } + + s, err = advCall(t, ws.patchFile, map[string]interface{}{ + "path": "dangling.txt", + "patch": "<<<<<<< SEARCH\n=======\nPWNED\n>>>>>>> REPLACE", + }) + if err == nil && !strings.Contains(s, "escape") && !strings.Contains(s, "refus") { + t.Errorf("ws_patch (create form) through dangling symlink not refused: %q", s) + } + if _, serr := os.Stat(victim); serr == nil { + t.Fatalf("ws_patch created %s outside the root", victim) + } + + s, err = advCall(t, ws.moveFile, map[string]interface{}{"from": "src.txt", "to": "dangling.txt"}) + if err == nil && !strings.Contains(s, "escape") && !strings.Contains(s, "refus") { + t.Errorf("ws_mv onto dangling symlink not refused: %q", s) + } + if _, serr := os.Stat(victim); serr == nil { + t.Fatalf("ws_mv created %s outside the root", victim) + } + if _, serr := os.Stat(filepath.Join(root, "src.txt")); serr != nil { + t.Fatalf("ws_mv source vanished: %v", serr) + } + + // A dangling link whose target stays INSIDE the root is still usable. + _ = os.Symlink(filepath.Join(root, "inner-target.txt"), filepath.Join(root, "inner-link.txt")) + if _, err := ws.resolve("inner-link.txt"); err != nil { + t.Errorf("in-root dangling symlink wrongly refused: %v", err) + } + }) } func TestSymlinkLoopAndProc(t *testing.T) { diff --git a/pkg/workspace/coerce.go b/pkg/workspace/coerce.go index 7abb57a..d9722b7 100644 --- a/pkg/workspace/coerce.go +++ b/pkg/workspace/coerce.go @@ -131,3 +131,30 @@ func strArg(args map[string]interface{}, key string) string { return "" } } + +// Argument aliases. Small models mix vocabularies from every editing tool they +// were trained on — old_string/new_string, search/replace, plain old/new — and +// a strict key match turned each of those into a wasted turn ("old_str is +// required"). The canonical key always wins; an alias is consulted only when +// the canonical key is absent or empty. +var ( + oldStrKeys = []string{"old_str", "old_string", "search", "old", "old_text", "before"} + newStrKeys = []string{"new_str", "new_string", "replace", "new", "new_text", "replacement", "after"} + contentKeys = []string{"content", "contents", "text", "body", "data"} + patchKeys = []string{"patch", "diff", "hunk"} +) + +// strArgAny returns the first non-empty string among keys (see strArg). +func strArgAny(args map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v := strArg(args, k); v != "" { + return v + } + } + return "" +} + +// aliasList renders the accepted spellings for an error message. +func aliasList(keys []string) string { + return strings.Join(keys, " / ") +} diff --git a/pkg/workspace/ergonomics_test.go b/pkg/workspace/ergonomics_test.go new file mode 100644 index 0000000..d6383a4 --- /dev/null +++ b/pkg/workspace/ergonomics_test.go @@ -0,0 +1,202 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// Edit-tool ergonomics for small models: argument aliases, the empty-content +// refusal, automatic gutter stripping and replace_all on drifted matches. + +func TestEditAcceptsArgAliases(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + args map[string]interface{} + }{ + {"claude-style", map[string]interface{}{"old_string": "b = 2", "new_string": "b = 20"}}, + {"aider-style", map[string]interface{}{"search": "b = 2", "replace": "b = 20"}}, + {"bare", map[string]interface{}{"old": "b = 2", "new": "b = 20"}}, + {"canonical-wins", map[string]interface{}{"old_str": "b = 2", "old": "nope", "new_str": "b = 20", "new": "nope"}}, + } { + t.Run(tc.name, func(t *testing.T) { + w, root := newTestWS(t) + p := filepath.Join(root, "f.txt") + if err := os.WriteFile(p, []byte("a = 1\nb = 2\nc = 3\n"), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("f.txt") + args := map[string]interface{}{"path": "f.txt"} + for k, v := range tc.args { + args[k] = v + } + out := strOut(w.editFile(ctx, args)) + if !strings.HasPrefix(out, "edited f.txt") { + t.Fatalf("alias edit refused: %q", out) + } + got, _ := os.ReadFile(p) + if string(got) != "a = 1\nb = 20\nc = 3\n" { + t.Fatalf("content after alias edit: %q", got) + } + }) + } + + t.Run("write-contents-alias", func(t *testing.T) { + w, root := newTestWS(t) + w.SyntaxCheck = false + out := strOut(w.writeFile(ctx, map[string]interface{}{"path": "n.txt", "contents": "hello\n"})) + if !strings.HasPrefix(out, "wrote n.txt") { + t.Fatalf("contents alias not honored: %q", out) + } + got, _ := os.ReadFile(filepath.Join(root, "n.txt")) + if string(got) != "hello\n" { + t.Fatalf("content: %q", got) + } + }) + + t.Run("patch-diff-alias", func(t *testing.T) { + w, root := newTestWS(t) + w.SyntaxCheck = false + if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("a\nb\nc\n"), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("f.txt") + out := strOut(w.patchFile(ctx, map[string]interface{}{ + "path": "f.txt", "diff": "<<<<<<< SEARCH\nb\n=======\nB\n>>>>>>> REPLACE", + })) + if !strings.HasPrefix(out, "patched f.txt") { + t.Fatalf("diff alias not honored: %q", out) + } + }) +} + +func TestWriteRefusesEmptyContentNamesAliases(t *testing.T) { + ctx := context.Background() + w, root := newTestWS(t) + w.SyntaxCheck = false + + out := strOut(w.writeFile(ctx, map[string]interface{}{"path": "new.go", "code": "package x\n"})) + if !strings.Contains(out, "Write refused") || !strings.Contains(out, "0-byte") { + t.Fatalf("empty write not refused: %q", out) + } + for _, k := range []string{"content", "contents", "text", "body"} { + if !strings.Contains(out, k) { + t.Errorf("message does not name accepted key %q: %q", k, out) + } + } + if !strings.Contains(out, "code") { + t.Errorf("message should point at the unrecognized key the model used: %q", out) + } + if _, err := os.Stat(filepath.Join(root, "new.go")); err == nil { + t.Fatal("0-byte file was created") + } + + // Existing file: same trap truncates; allow_shrink is the way through. + if err := os.WriteFile(filepath.Join(root, "old.txt"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("old.txt") + out = strOut(w.writeFile(ctx, map[string]interface{}{"path": "old.txt", "content": ""})) + if !strings.Contains(out, "truncate") || !strings.Contains(out, "allow_shrink") { + t.Fatalf("empty overwrite not refused with the escape hatch named: %q", out) + } + if got, _ := os.ReadFile(filepath.Join(root, "old.txt")); string(got) != "keep" { + t.Fatalf("file truncated: %q", got) + } + out = strOut(w.writeFile(ctx, map[string]interface{}{"path": "old.txt", "content": "", "allow_shrink": true})) + if !strings.HasPrefix(out, "overwrote old.txt (0 bytes)") { + t.Fatalf("allow_shrink empty write: %q", out) + } +} + +func TestEditStripsGutterAutomatically(t *testing.T) { + ctx := context.Background() + w, root := newTestWS(t) + w.SyntaxCheck = false + src := "package x\n\nfunc f() {\n\tif err != nil {\n\t\treturn err\n\t}\n}\n" + if err := os.WriteFile(filepath.Join(root, "f.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("f.go") + + out := strOut(w.editFile(ctx, map[string]interface{}{ + "path": "f.go", + "old_str": " 4|\tif err != nil {\n 5|\t\treturn err\n 6|\t}", + "new_str": " 4|\tif err != nil {\n 5|\t\treturn fmt.Errorf(\"f: %w\", err)\n 6|\t}", + })) + if !strings.HasPrefix(out, "edited f.go (1 replacement(s))") { + t.Fatalf("gutter edit did not apply: %q", out) + } + if !strings.Contains(out, "[stripped ws_read line numbers from old_str") { + t.Fatalf("note missing so evolve cannot see the drift: %q", out) + } + got, _ := os.ReadFile(filepath.Join(root, "f.go")) + if !strings.Contains(string(got), "\t\treturn fmt.Errorf(\"f: %w\", err)\n") || strings.Contains(string(got), "|") { + t.Fatalf("content after gutter strip: %q", got) + } + + // A PARTIAL gutter is not a paste: still refused by name. + out = strOut(w.editFile(ctx, map[string]interface{}{ + "path": "f.go", "old_str": " 4|\tif err != nil {\n\t\treturn err", "new_str": "x", + })) + if !strings.Contains(out, "line-number prefix") { + t.Fatalf("mixed gutter should still be refused: %q", out) + } + + // ws_patch: same treatment, marker lines exempt. + out = strOut(w.patchFile(ctx, map[string]interface{}{ + "path": "f.go", + "patch": "<<<<<<< SEARCH\n 1|package x\n=======\n 1|package y\n>>>>>>> REPLACE", + })) + if !strings.HasPrefix(out, "patched f.go") || !strings.Contains(out, "[stripped ws_read line numbers") { + t.Fatalf("patch gutter strip: %q", out) + } + got, _ = os.ReadFile(filepath.Join(root, "f.go")) + if !strings.HasPrefix(string(got), "package y\n") { + t.Fatalf("patched content: %q", got) + } +} + +func TestReplaceAllOnDriftedMatches(t *testing.T) { + ctx := context.Background() + w, root := newTestWS(t) + w.SyntaxCheck = false + // Three call sites, all indented deeper than the model's old_str. + src := "func a() {\n\t\tlog.Println(\"x\")\n}\nfunc b() {\n\t\tlog.Println(\"x\")\n}\nfunc c() {\n\t\tlog.Println(\"x\")\n}\n" + if err := os.WriteFile(filepath.Join(root, "f.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("f.go") + + // Without replace_all the ladder is (correctly) ambiguous. + out := strOut(w.editFile(ctx, map[string]interface{}{ + "path": "f.go", "old_str": "log.Println(\"x\")", "new_str": "slog.Info(\"x\")", + })) + if !strings.Contains(out, "Ambiguous") && !strings.Contains(out, "found 3 times") { + t.Fatalf("expected ambiguity without replace_all: %q", out) + } + // Exact count is 3 here (the search has no leading whitespace), so drive + // the LADDER path with trailing-whitespace drift instead. + out = strOut(w.editFile(ctx, map[string]interface{}{ + "path": "f.go", "old_str": "\tlog.Println(\"x\") ", "new_str": "\tslog.Info(\"x\")", "replace_all": true, + })) + if !strings.HasPrefix(out, "edited f.go (3 replacement(s))") { + t.Fatalf("replace_all on drifted matches: %q", out) + } + if !strings.Contains(out, "[matched") { + t.Fatalf("strategy note missing: %q", out) + } + got, _ := os.ReadFile(filepath.Join(root, "f.go")) + if strings.Count(string(got), "slog.Info") != 3 || strings.Contains(string(got), "log.Println") { + t.Fatalf("content after replace_all: %q", got) + } + + // Helper-level: overlapping exact spans are deduplicated. + next, n, via := ReplaceAllDrifted("aaa", "aa", "b") + if n != 1 || next != "ba" || via != MatchExact { + t.Fatalf("ReplaceAllDrifted(aaa,aa,b) = %q n=%d via=%s", next, n, via) + } +} diff --git a/pkg/workspace/match.go b/pkg/workspace/match.go index 7eebd00..9cde428 100644 --- a/pkg/workspace/match.go +++ b/pkg/workspace/match.go @@ -83,6 +83,77 @@ func FindEditMatchIn(content, search string, lo, hi int) MatchOutcome { return out } +// FindAllEditMatches runs the ladder and returns EVERY span of the first +// strategy that matches at least once, plus that strategy's name. It is the +// replace_all counterpart of FindEditMatch: when the exact search misses but a +// tolerant rung finds the drifted text in N places, the caller asked for all of +// them and should get all of them. Spans are ordered by offset and never +// overlap. +func FindAllEditMatches(content, search string) ([]EditMatch, string) { + if search == "" || content == "" { + return nil, "" + } + for _, strategy := range matchLadder { + spans := matchStrategy(content, search, 0, len(content), strategy) + if len(spans) > 0 { + return disjointSpans(spans), strategy + } + } + return nil, "" +} + +// disjointSpans keeps the earliest of any overlapping spans (exactSpans +// advances one byte at a time, so "aa" in "aaa" yields two overlapping hits). +func disjointSpans(spans []EditMatch) []EditMatch { + out := spans[:0:0] + end := -1 + for _, sp := range spans { + if sp.Start < end { + continue + } + out = append(out, sp) + end = sp.End + } + return out +} + +// ReplaceAllDrifted is replace_all for the case where the exact search misses: +// every span of the first ladder strategy that matches is replaced, then the +// ladder is run again until nothing matches (the per-strategy scans cap their +// hit lists, so one pass is not guaranteed to be exhaustive). It returns the +// new content, how many spans were replaced and which strategy found them. +// A replacement that still matches its own search under the tolerant strategy +// is bounded by the round limit and the unchanged-content check rather than +// looping forever. +func ReplaceAllDrifted(content, oldStr, newStr string) (string, int, string) { + total := 0 + first := "" + for round := 0; round < 16; round++ { + spans, strategy := FindAllEditMatches(content, oldStr) + if len(spans) == 0 { + break + } + if first == "" { + first = strategy + } + next := content + for i := len(spans) - 1; i >= 0; i-- { + next = ApplyEditReplacement(next, spans[i], oldStr, newStr) + } + if next == content { + break + } + content = next + total += len(spans) + // Only a capped scan can have left matches behind (lineSpans stops + // at 9, exactSpans at 65); a shorter list was already exhaustive. + if len(spans) < 9 { + break + } + } + return content, total, first +} + func matchStrategy(content, search string, lo, hi int, strategy string) []EditMatch { switch strategy { case MatchExact: diff --git a/pkg/workspace/observer.go b/pkg/workspace/observer.go index 8c2c5a5..b83d5ad 100644 --- a/pkg/workspace/observer.go +++ b/pkg/workspace/observer.go @@ -108,6 +108,30 @@ func (w *Workspace) observed(name string, fn tools.ToolExecutor) tools.ToolExecu if obs == nil { return out, err } + if rep, ok := selfRepaired(name, args, resultString(out), err); ok { + // The tool repaired the call itself (a ws_read gutter stripped + // from old_str). The self-improvement engine must still see the + // drift exactly as it would have before — a refusal, then a + // repaired retry that landed — so the failure is fingerprinted, + // the memory rule is credited and the metrics row moves. What + // must NOT happen is a second edit: the file already changed, so + // the observer's retry is reported, not re-run. + d := time.Since(start) + adv := obs(ctx, ToolCall{Tool: name, Args: args, Result: rep.refusal, Duration: d}) + retryArgs := adv.RetryArgs + if len(retryArgs) == 0 { + retryArgs = rep.args + } + retry := ToolCall{Tool: name, Args: retryArgs, Result: resultString(out), Duration: d, Retried: true} + adv2 := obs(ctx, retry) + if sink != nil && len(adv.RetryArgs) > 0 { + sink(adv.RetryArgs, true, retry.Result) + } + if adv2.Guidance != "" { + return appendGuidance(retry.Result, adv2.Guidance), nil + } + return out, err + } call := ToolCall{ Tool: name, Args: args, @@ -157,6 +181,42 @@ func (w *Workspace) observed(name string, fn tools.ToolExecutor) tools.ToolExecu } } +// selfRepair describes a call the tool layer fixed on its own: the refusal it +// would have produced before it learned to, and the arguments as repaired. +type selfRepair struct { + refusal string + args map[string]interface{} +} + +// selfRepaired recognizes a result produced by an in-tool repair. Today that +// is the ws_read gutter stripped from ws_edit/ws_patch input, marked by +// GutterStrippedNote on the result. +func selfRepaired(name string, args map[string]interface{}, result string, err error) (selfRepair, bool) { + if err != nil || !strings.Contains(result, GutterStrippedNote) { + return selfRepair{}, false + } + repaired := make(map[string]interface{}, len(args)+2) + for k, v := range args { + repaired[k] = v + } + switch name { + case "ws_edit": + if s, ok := stripReadGutter(strArgAny(args, oldStrKeys...)); ok { + repaired["old_str"] = s + } + if s, ok := stripReadGutter(strArgAny(args, newStrKeys...)); ok { + repaired["new_str"] = s + } + case "ws_patch": + if s, ok := stripReadGutterPatch(strArgAny(args, patchKeys...)); ok { + repaired["patch"] = s + } + default: + return selfRepair{}, false + } + return selfRepair{refusal: LineNumberedOldStrReason(strArg(args, "path")), args: repaired}, true +} + // resultString renders a tool result as the agent would see it. func resultString(out interface{}) string { if s, ok := out.(string); ok { diff --git a/pkg/workspace/observer_test.go b/pkg/workspace/observer_test.go index b405200..8248a94 100644 --- a/pkg/workspace/observer_test.go +++ b/pkg/workspace/observer_test.go @@ -97,7 +97,7 @@ func TestObserverRepairsAndRetriesInPlace(t *testing.T) { }, nil) out := callTool(t, reg, "ws_edit", map[string]interface{}{ - "path": "a.go", "old_str": " 3|var A = 1", "new_str": "var A = 2", + "path": "a.go", "old_str": " 3|var A = 1\nvar B", "new_str": "var A = 2", }) if !strings.Contains(out, "edited a.go") { t.Fatalf("the repaired retry did not land: %s", out) @@ -163,7 +163,7 @@ func TestRetrySinkReportsTheVerdict(t *testing.T) { called, gotOK = true, ok }) callTool(t, reg, "ws_edit", map[string]interface{}{ - "path": "a.go", "old_str": " 1|var A = 1", "new_str": "var A = 2", + "path": "a.go", "old_str": " 1|var A = 1\nvar B", "new_str": "var A = 2", }) if !called { t.Fatal("the retry sink was never called") @@ -201,7 +201,7 @@ func TestRetrySinkReportsAFailedRepairAsFailed(t *testing.T) { called, gotOK = true, ok }) callTool(t, reg, "ws_edit", map[string]interface{}{ - "path": "a.go", "old_str": " 1|var A = 1", "new_str": "var A = 2", + "path": "a.go", "old_str": " 1|var A = 1\nvar B", "new_str": "var A = 2", }) if !called { t.Fatal("the retry sink was never called") diff --git a/pkg/workspace/patch.go b/pkg/workspace/patch.go index 8b13586..60a47c9 100644 --- a/pkg/workspace/patch.go +++ b/pkg/workspace/patch.go @@ -26,23 +26,15 @@ func ApplyPatch(content, patch string) (next string, summary string, err error) return "", "", fmt.Errorf("rejected bad patch format — use a single SEARCH/REPLACE or one unified diff hunk (no prose, no multiple files)") } - if strings.Contains(patch, "<<<<<<<") && strings.Contains(patch, ">>>>>>>") { + if strings.Contains(patch, "<<<<<<<") { return applySearchReplace(content, patch) } - if strings.Contains(patch, "=======") && (strings.Contains(patch, "SEARCH") || strings.Contains(patch, "REPLACE")) { - return applySearchReplace(content, patch) - } - // SEARCH / REPLACE without git conflict markers - if i := strings.Index(patch, "\n=======\n"); i >= 0 { - oldPart := strings.TrimPrefix(patch, "SEARCH\n") - if j := strings.Index(oldPart, "\n=======\n"); j >= 0 { - oldStr := strings.TrimPrefix(oldPart[:j], "SEARCH\n") - rest := oldPart[j+len("\n=======\n"):] - rest = strings.TrimPrefix(rest, "REPLACE\n") - rest = strings.TrimSuffix(rest, "\n>>>>>>> REPLACE") - rest = strings.TrimSuffix(rest, ">>>>>>> REPLACE") - return applyExact(content, strings.TrimRight(oldStr, "\n"), strings.TrimRight(rest, "\n")) - } + // SEARCH / REPLACE without the git conflict markers: "SEARCH\n…\n=======\n…" + // with or without a REPLACE header or a ">>>>>>> REPLACE" trailer. This + // form used to be routed to applySearchReplace (which insists on <<<<<<<) + // whenever the words SEARCH/REPLACE appeared, so it was unreachable. + if oldStr, newStr, ok := splitBareSearchReplace(patch); ok { + return applyExact(content, oldStr, newStr) } if strings.Contains(patch, "@@") || strings.HasPrefix(patch, "---") || looksLikeDiff(patch) { return applyUnifiedHunks(content, patch) @@ -75,6 +67,54 @@ func applySearchReplace(content, patch string) (string, string, error) { return applyExact(content, oldBlock, newBlock) } +// splitBareSearchReplace parses the marker-less SEARCH/REPLACE form. The +// separator is a line that is exactly "=======" (the FIRST one — a later one +// may be a Markdown rule inside the replacement). An optional "SEARCH" header +// line, an optional "REPLACE" header line after the separator and an optional +// ">>>>>>> REPLACE" trailer are all tolerated and dropped. +func splitBareSearchReplace(patch string) (oldStr, newStr string, ok bool) { + lines := strings.Split(patch, "\n") + sep := -1 + for i, ln := range lines { + // Exactly "=======" at column 0: a unified-diff context line reads + // " =======" and a removed one "-=======", neither is a separator. + if strings.TrimRight(ln, " \t\r") == "=======" { + sep = i + break + } + } + if sep < 0 { + return "", "", false + } + before := lines[:sep] + after := lines[sep+1:] + isHeader := func(ln, word string) bool { + t := strings.ToUpper(strings.TrimSpace(ln)) + return t == word || t == word+":" + } + if len(before) > 0 && isHeader(before[0], "SEARCH") { + before = before[1:] + } + if len(after) > 0 && isHeader(after[0], "REPLACE") { + after = after[1:] + } + if n := len(after); n > 0 { + t := strings.TrimSpace(after[n-1]) + if strings.HasPrefix(t, ">>>>>>>") || strings.EqualFold(t, "REPLACE") { + after = after[:n-1] + } + } + // The word SEARCH or REPLACE somewhere in the text is not enough on its + // own: a unified diff that happens to touch a line saying "=======" must + // still be a unified diff. + if strings.Contains(patch, "@@") && !strings.Contains(strings.ToUpper(patch), "SEARCH") { + return "", "", false + } + oldStr = strings.TrimRight(strings.Join(before, "\n"), "\n") + newStr = strings.TrimRight(strings.Join(after, "\n"), "\n") + return oldStr, newStr, true +} + // applyExact locates oldStr with the full match ladder and refuses to apply a // non-unique match. The old behavior (strings.Replace ..., 1 — first // occurrence wins) silently patched the wrong function. @@ -142,8 +182,26 @@ func ParseUnifiedHunks(patch string) ([]hunk, error) { var hunks []hunk var cur *hunk seenHunk := false + // pending holds unprefixed lines that have not yet been proven to be + // context. An unprefixed line FOLLOWED by a real diff line is context a + // model forgot to prefix (blank lines especially); an unprefixed line at + // the END of a hunk is trailing prose ("This adds the check.") or a + // closing code fence, and the old parser turned both into context lines + // that could never match, failing the whole hunk. + var pending []string + flush := func() { + if cur == nil { + return + } + for _, l := range pending { + cur.Old = append(cur.Old, l) + cur.New = append(cur.New, l) + } + pending = nil + } for _, line := range lines { if strings.HasPrefix(line, "@@") { + pending = nil // trailing prose of the previous hunk if cur != nil { hunks = append(hunks, *cur) } @@ -158,25 +216,35 @@ func ParseUnifiedHunks(patch string) ([]hunk, error) { if line == `\ No newline at end of file` { continue } - if cur == nil { - // Bare -/+ block with no @@ header: open an anchorless hunk. - cur = &hunk{Header: "(no @@ header)"} - seenHunk = true + if strings.HasPrefix(strings.TrimSpace(line), "```") { + // A Markdown fence is never file content on either side. + continue } switch { - case strings.HasPrefix(line, "-"): - cur.Old = append(cur.Old, line[1:]) - case strings.HasPrefix(line, "+"): - cur.New = append(cur.New, line[1:]) - case strings.HasPrefix(line, " "): - cur.Old = append(cur.Old, line[1:]) - cur.New = append(cur.New, line[1:]) + case strings.HasPrefix(line, "-"), strings.HasPrefix(line, "+"), strings.HasPrefix(line, " "): + if cur == nil { + // Bare -/+ block with no @@ header: open an anchorless hunk. + cur = &hunk{Header: "(no @@ header)"} + seenHunk = true + // Leading prose ("Here is the diff:") is not context; a + // leading unprefixed CODE line is kept, as before. + pending = dropLeadingProse(pending) + } + flush() + switch line[0] { + case '-': + cur.Old = append(cur.Old, line[1:]) + case '+': + cur.New = append(cur.New, line[1:]) + default: + cur.Old = append(cur.Old, line[1:]) + cur.New = append(cur.New, line[1:]) + } default: - // A completely empty line inside a hunk is an unprefixed blank - // CONTEXT line (many models emit "" instead of " "). Dropping it - // desynchronised both sides of the diff. - cur.Old = append(cur.Old, line) - cur.New = append(cur.New, line) + // An unprefixed line: blank context a model emitted as "" instead + // of " ", forgotten-prefix context, or prose. Decided when we see + // what follows it. + pending = append(pending, line) } } if cur != nil { @@ -442,27 +510,80 @@ func closestTextHint(content string, oldLines []string, near int) string { fmt.Sprintf("\n(lines %d–%d — copy this text verbatim, no line numbers)", lo+1, hi) } -// insertHunk places a pure-addition hunk at its @@ line, or appends. +// dropLeadingProse trims blank lines and sentence-shaped lines ("Here is the +// diff:") from the front of an anchorless hunk's unprefixed prefix, keeping a +// leading code line the model merely forgot to prefix. +func dropLeadingProse(pending []string) []string { + for len(pending) > 0 { + t := strings.TrimSpace(pending[0]) + if t == "" || (strings.HasSuffix(t, ":") && strings.Contains(t, " ")) { + pending = pending[1:] + continue + } + break + } + return pending +} + +// insertHunk places a pure-addition hunk, or appends when it has no anchor. +// +// A hunk with no removed lines ("@@ -N,0 +M,K @@") has unified-diff semantics +// of "insert AFTER line N" (N = 0 means at the very top). It used to be +// inserted BEFORE line N, one line too early — an import added "after line 3" +// landed between lines 2 and 3. When the hunk does carry old lines that are +// all blank (blank context around the insertion), those blank lines are +// replaced in place when the file has them at the anchor, otherwise the block +// goes before the anchor as before. The returned int is the 1-based line the +// new text now starts on (0 for an unanchored append). func insertHunk(content string, h hunk, newStr string, lineDelta int) (string, int) { - if h.NewStart <= 0 && h.OldStart <= 0 { + appendAtEnd := func() string { if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } - return content + newStr + "\n", 0 + return content + newStr + "\n" + } + if h.NewStart <= 0 && h.OldStart <= 0 && !strings.HasPrefix(h.Header, "@@") { + // Headerless (anchorless) additions: nowhere to anchor, append. + return appendAtEnd(), 0 + } + li := indexLines(content) + if len(h.Old) == 0 { + // Pure insertion: after line N. + after := h.OldStart + lineDelta + if after < 0 { + after = 0 + } + if after >= len(li.starts) || (after == len(li.starts)-1 && li.text[after] == "" && strings.HasSuffix(content, "\n")) { + return appendAtEnd(), after + 1 + } + off := li.starts[after] + return content[:off] + newStr + "\n" + content[off:], after + 1 } + // Blank context lines on the old side: replace them in place when they + // really are blank in the file at the anchor. at := h.OldStart + lineDelta if at <= 0 { at = h.NewStart } - li := indexLines(content) if at < 1 { at = 1 } if at > len(li.starts) { - if content != "" && !strings.HasSuffix(content, "\n") { - content += "\n" + return appendAtEnd(), len(li.starts) + 1 + } + if end := at - 1 + len(h.Old); end <= len(li.starts) { + blank := true + for i := at - 1; i < end; i++ { + if strings.TrimSpace(li.text[i]) != "" { + blank = false + break + } + } + if blank { + lo := li.starts[at-1] + hi := li.ends[end-1] + return content[:lo] + newStr + content[hi:], at } - return content + newStr + "\n", len(li.starts) + 1 } off := li.starts[at-1] return content[:off] + newStr + "\n" + content[off:], at diff --git a/pkg/workspace/patch_semantics_test.go b/pkg/workspace/patch_semantics_test.go new file mode 100644 index 0000000..e23702e --- /dev/null +++ b/pkg/workspace/patch_semantics_test.go @@ -0,0 +1,145 @@ +package workspace + +import ( + "strings" + "testing" +) + +// Pure-insertion hunks ("-N,0") insert AFTER line N, as in every unified-diff +// consumer; they used to land one line early. +func TestApplyPatchPureInsertionIsAfterLine(t *testing.T) { + content := "a\nb\nc\n" + for _, tc := range []struct { + name, patch, want string + }{ + {"after line 1", "@@ -1,0 +2,1 @@\n+X", "a\nX\nb\nc\n"}, + {"top of file (line 0)", "@@ -0,0 +1,1 @@\n+X", "X\na\nb\nc\n"}, + {"after last line", "@@ -3,0 +4,1 @@\n+X", "a\nb\nc\nX\n"}, + {"two lines after line 2", "@@ -2,0 +3,2 @@\n+X\n+Y", "a\nb\nX\nY\nc\n"}, + {"no trailing newline", "@@ -2,0 +3,1 @@\n+X", ""}, // handled below + } { + t.Run(tc.name, func(t *testing.T) { + src := content + want := tc.want + if tc.want == "" { + src = "a\nb" + want = "a\nb\nX\n" + } + got, _, err := ApplyPatch(src, tc.patch) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("got %q want %q", got, want) + } + }) + } + + // Multi-hunk: the line delta of an earlier insertion carries forward. + got, _, err := ApplyPatch("a\nb\nc\nd\n", "@@ -1,0 +2,1 @@\n+X\n@@ -3,0 +5,1 @@\n+Y") + if err != nil { + t.Fatal(err) + } + if got != "a\nX\nb\nc\nY\nd\n" { + t.Fatalf("multi-hunk insertion: %q", got) + } + + // Empty file, GNU-style header. + got, _, err = ApplyPatch("", "@@ -0,0 +1,2 @@\n+one\n+two") + if err != nil { + t.Fatal(err) + } + if got != "one\ntwo\n" { + t.Fatalf("insert into empty file: %q", got) + } +} + +// The marker-less SEARCH/REPLACE form was routed to the <<<<<<< parser, which +// rejected it as malformed, so it never worked at all. +func TestApplyPatchBareSearchReplace(t *testing.T) { + content := "package a\n\nfunc F() int { return 1 }\n" + for _, tc := range []struct{ name, patch string }{ + {"SEARCH/REPLACE headers", "SEARCH\nfunc F() int { return 1 }\n=======\nREPLACE\nfunc F() int { return 2 }"}, + {"SEARCH header only", "SEARCH\nfunc F() int { return 1 }\n=======\nfunc F() int { return 2 }"}, + {"trailer only", "func F() int { return 1 }\n=======\nfunc F() int { return 2 }\n>>>>>>> REPLACE"}, + {"colon headers", "SEARCH:\nfunc F() int { return 1 }\n=======\nREPLACE:\nfunc F() int { return 2 }"}, + {"no words at all", "func F() int { return 1 }\n=======\nfunc F() int { return 2 }"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, summary, err := ApplyPatch(content, tc.patch) + if err != nil { + t.Fatalf("bare form rejected: %v", err) + } + if !strings.Contains(got, "return 2") || strings.Contains(got, "return 1") { + t.Fatalf("got %q", got) + } + if strings.Contains(got, "SEARCH") || strings.Contains(got, "REPLACE") || strings.Contains(got, "=======") { + t.Fatalf("headers leaked into the file: %q (summary %q)", got, summary) + } + }) + } + + // A "=======" INSIDE a replacement (Markdown rule) after the first + // separator is content, not a second separator. + got, _, err := ApplyPatch("# T\n\nbody\n", "SEARCH\nbody\n=======\nbody\n\nRule\n=======\n") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "Rule\n=======") { + t.Fatalf("second ======= was not kept as content: %q", got) + } + + // A unified-diff context line " =======" is not a bare separator. + got, _, err = ApplyPatch("x\n=======\ny\n", "@@ -1,3 +1,3 @@\n-x\n+X\n =======\n y") + if err != nil { + t.Fatal(err) + } + if got != "X\n=======\ny\n" { + t.Fatalf("diff with ======= context: %q", got) + } +} + +// Trailing prose and Markdown fences around a hunk are dropped instead of +// becoming context lines that can never match. +func TestApplyPatchIgnoresTrailingProseAndFences(t *testing.T) { + content := "a\nb\nc\n" + for _, tc := range []struct{ name, patch string }{ + {"closing fence", "@@ -2,1 +2,1 @@\n-b\n+B\n```"}, + {"opening and closing fence", "```diff\n@@ -2,1 +2,1 @@\n-b\n+B\n```"}, + {"trailing sentence", "@@ -2,1 +2,1 @@\n-b\n+B\nThis changes b to B."}, + {"fence then sentence", "@@ -2,1 +2,1 @@\n-b\n+B\n```\nThat should do it."}, + // ("Here is the patch:" essays are still rejected outright by the + // anti-wander junk rule; a shorter lead-in reaches the parser.) + {"anchorless with leading prose", "Apply this change:\n-b\n+B"}, + {"anchorless with trailing prose", "-b\n+B\nDone."}, + } { + t.Run(tc.name, func(t *testing.T) { + got, _, err := ApplyPatch(content, tc.patch) + if err != nil { + t.Fatalf("patch rejected: %v", err) + } + if got != "a\nB\nc\n" { + t.Fatalf("got %q", got) + } + }) + } + + // An unprefixed line FOLLOWED by a diff line is still context (models + // drop the leading space), and blank context in the middle still counts. + got, _, err := ApplyPatch("a\n\nb\nc\n", "@@ -1,4 +1,4 @@\na\n\n-b\n+B\nc") + if err != nil { + t.Fatal(err) + } + if got != "a\n\nB\nc\n" { + t.Fatalf("unprefixed context: %q", got) + } + + // Trailing unprefixed lines in a MULTI-hunk diff are dropped per hunk. + got, _, err = ApplyPatch("a\nb\nc\nd\n", "@@ -1,1 +1,1 @@\n-a\n+A\nnote one\n@@ -3,1 +3,1 @@\n-c\n+C\nnote two") + if err != nil { + t.Fatal(err) + } + if got != "A\nb\nC\nd\n" { + t.Fatalf("multi-hunk with trailing notes: %q", got) + } +} diff --git a/pkg/workspace/pending.go b/pkg/workspace/pending.go new file mode 100644 index 0000000..b8dba24 --- /dev/null +++ b/pkg/workspace/pending.go @@ -0,0 +1,146 @@ +package workspace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/UnicoLab/slmcode/pkg/internal/atomicfile" + "github.com/UnicoLab/slmcode/pkg/permissions" +) + +// Review-queue recording (permission mode "review"). +// +// permissions.RecordPending writes the queue entry {path, kind, content}. The +// workspace knows more than that at the moment of the proposal — which task +// and role produced it, which query is running, and for a move where the file +// came FROM — and the appliers (Studio's review page, `slmcode apply`) need +// the extra fields to act on a kind correctly and to attribute the change. +// So the entry is stamped here, in place, right after it is recorded. + +// PendingStamp is the attribution added to every review-queue entry. +type PendingStamp struct { + // From is the source path of a ws_mv proposal ("" for other kinds). + From string `json:"from,omitempty"` + // TaskID is the board task whose tool call produced the proposal, from the + // context tag pkg/loop sets per dispatch (WithTaskID). "" when untagged. + TaskID string `json:"task_id,omitempty"` + // Agent is the role that made the call (WithRole). "" when untagged. + Agent string `json:"agent,omitempty"` + // QueryID is the id of the running query, read from the live board + // (.slmcode/board.json) at record time. "" when no board is live. + QueryID string `json:"query_id,omitempty"` +} + +// PendingHook observes a review-queue entry being recorded. Studio installs +// one to emit a `review_pending` event with the new queue depth. +type PendingHook func(path, kind, file string) + +var ( + pendingHookMu sync.RWMutex + pendingHooks = map[int]PendingHook{} + pendingHookID int +) + +// AddPendingHook registers fn and returns a function that removes it again. +// Hooks run synchronously after the queue file exists on disk, never under a +// workspace lock. +func AddPendingHook(fn PendingHook) (remove func()) { + if fn == nil { + return func() {} + } + pendingHookMu.Lock() + pendingHookID++ + id := pendingHookID + pendingHooks[id] = fn + pendingHookMu.Unlock() + return func() { + pendingHookMu.Lock() + delete(pendingHooks, id) + pendingHookMu.Unlock() + } +} + +func firePendingHooks(path, kind, file string) { + pendingHookMu.RLock() + hooks := make([]PendingHook, 0, len(pendingHooks)) + for _, h := range pendingHooks { + hooks = append(hooks, h) + } + pendingHookMu.RUnlock() + for _, h := range hooks { + h(path, kind, file) + } +} + +// recordPending writes the queue entry, stamps it, and notifies hooks. It +// returns the queue file path. +func (w *Workspace) recordPending(ctx context.Context, path, kind, content, from string) (string, error) { + file, err := permissions.RecordPending(w.SlmDir, path, kind, content) + if err != nil { + return "", err + } + stamp := PendingStamp{ + From: from, + TaskID: TaskIDFrom(ctx), + Agent: RoleFrom(ctx), + QueryID: liveQueryID(w.SlmDir), + } + // Best effort: the entry is complete without the stamp, so a failure to + // stamp must not turn a recorded proposal into an error the model sees. + _ = stampPendingFile(file, stamp) + firePendingHooks(path, kind, filepath.Base(file)) + return file, nil +} + +// stampPendingFile merges stamp into the JSON object in file. +func stampPendingFile(file string, stamp PendingStamp) error { + raw, err := os.ReadFile(file) //nolint:gosec // path produced by permissions.RecordPending under SlmDir + if err != nil { + return err + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return err + } + if stamp.From != "" { + obj["from"] = stamp.From + } + if stamp.TaskID != "" { + obj["task_id"] = stamp.TaskID + } + if stamp.Agent != "" { + obj["agent"] = stamp.Agent + } + if stamp.QueryID != "" { + obj["query_id"] = stamp.QueryID + } + out, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + return atomicfile.Write(file, append(out, '\n'), 0o644) +} + +// liveQueryID reads query_id from the live board mirror, if there is one. +// Review mode only ever reaches this on a proposal, so one small read per +// proposal is the whole cost. +func liveQueryID(slmDir string) string { + if slmDir == "" { + return "" + } + raw, err := os.ReadFile(filepath.Join(slmDir, "board.json")) //nolint:gosec // harness state under SlmDir + if err != nil { + return "" + } + var b struct { + QueryID string `json:"query_id"` + } + if json.Unmarshal(raw, &b) != nil { + return "" + } + return strings.TrimSpace(b.QueryID) +} diff --git a/pkg/workspace/pending_test.go b/pkg/workspace/pending_test.go new file mode 100644 index 0000000..6a16fc2 --- /dev/null +++ b/pkg/workspace/pending_test.go @@ -0,0 +1,177 @@ +package workspace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/UnicoLab/slmcode/pkg/hitl" +) + +func reviewWS(t *testing.T) (*Workspace, string, string) { + t.Helper() + root := t.TempDir() + if real, err := filepath.EvalSymlinks(root); err == nil { + root = real + } + slm := filepath.Join(root, ".slmcode") + ws, _, err := NewWorkspace(root, ToolOpts{ + Permission: "review", ShellPermission: "ask", SlmDir: slm, + DisableSyntaxCheck: true, DisableReadBeforeEdit: true, ShellAskTimeout: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + return ws, root, slm +} + +func readQueue(t *testing.T, slm string) []map[string]any { + t.Helper() + entries, _ := os.ReadDir(filepath.Join(slm, "pending")) + var out []map[string]any + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".patch.json") { + continue + } + raw, err := os.ReadFile(filepath.Join(slm, "pending", e.Name())) //nolint:gosec // test-owned path + if err != nil { + t.Fatal(err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("%s: %v", e.Name(), err) + } + obj["_file"] = e.Name() + out = append(out, obj) + } + return out +} + +func TestReviewQueueEntriesAreStamped(t *testing.T) { + ws, root, slm := reviewWS(t) + if err := os.MkdirAll(slm, 0o750); err != nil { + t.Fatal(err) + } + // The live board mirror is where the query id comes from. + if err := os.WriteFile(filepath.Join(slm, "board.json"), []byte(`{"query_id":"run-42","tasks":[]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "old.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var hooked []string + remove := AddPendingHook(func(path, kind, file string) { + mu.Lock() + hooked = append(hooked, kind+":"+path) + mu.Unlock() + }) + defer remove() + + ctx := WithRole(WithTaskID(context.Background(), "T3"), "worker") + if out := strOut(ws.writeFile(ctx, map[string]interface{}{"path": "new.go", "content": "package b\n"})); !strings.HasPrefix(out, "review: staged new.go") { + t.Fatalf("write in review mode: %q", out) + } + if out := strOut(ws.moveFile(ctx, map[string]interface{}{"from": "old.go", "to": "moved.go"})); !strings.HasPrefix(out, "review: staged moved.go") { + t.Fatalf("mv in review mode: %q", out) + } + if out := strOut(ws.deleteFile(ctx, map[string]interface{}{"path": "old.go"})); !strings.HasPrefix(out, "review: staged old.go") { + t.Fatalf("delete in review mode: %q", out) + } + // Nothing was touched on disk. + if _, err := os.Stat(filepath.Join(root, "new.go")); err == nil { + t.Fatal("review mode wrote new.go") + } + if _, err := os.Stat(filepath.Join(root, "old.go")); err != nil { + t.Fatal("review mode removed old.go") + } + + q := readQueue(t, slm) + if len(q) != 3 { + t.Fatalf("queue has %d entries, want 3", len(q)) + } + byKind := map[string]map[string]any{} + for _, e := range q { + byKind[e["kind"].(string)] = e + } + for _, kind := range []string{"write", "mv", "delete"} { + e := byKind[kind] + if e == nil { + t.Fatalf("no %s entry", kind) + } + if e["task_id"] != "T3" || e["agent"] != "worker" || e["query_id"] != "run-42" { + t.Errorf("%s entry not stamped: %v", kind, e) + } + } + if byKind["mv"]["from"] != "old.go" || byKind["mv"]["path"] != "moved.go" || byKind["mv"]["content"] != "package a\n" { + t.Errorf("mv entry: %v", byKind["mv"]) + } + if byKind["delete"]["content"] != "" || byKind["delete"]["path"] != "old.go" { + t.Errorf("delete entry: %v", byKind["delete"]) + } + mu.Lock() + defer mu.Unlock() + if len(hooked) != 3 || hooked[0] != "write:new.go" || hooked[1] != "mv:moved.go" || hooked[2] != "delete:old.go" { + t.Errorf("hooks saw %v", hooked) + } +} + +func TestShellAskIsNotAQueueEntry(t *testing.T) { + ws, _, slm := reviewWS(t) + ws.OnShellAsk = func(ask ShellAsk) { + // Approve from "the UI" through the per-ask answer file. + go func() { + _ = hitl.WriteAnswerIDOnce(slm, "shell", ask.ID, ShellAnswer{AskID: ask.ID, Decision: "approve"}) + }() + } + ok, err := ws.waitShellApproval(context.Background(), "echo hi") + if err != nil || !ok { + t.Fatalf("approval: ok=%v err=%v", ok, err) + } + if q := readQueue(t, slm); len(q) != 0 { + t.Fatalf("a shell ask landed in the review queue: %v", q) + } + if ids, _ := hitl.ListAskIDs(slm, "shell"); len(ids) != 0 { + t.Fatalf("answered ask not cleared: %v", ids) + } +} + +// Two workers asking at once each get their own decision (the single-slot +// ask.json used to make the second ask erase the first). +func TestParallelShellAsksAreIndependent(t *testing.T) { + ws, _, slm := reviewWS(t) + decisions := map[string]string{"go test ./a": "approve", "rm -rf build": "deny"} + ws.OnShellAsk = func(ask ShellAsk) { + d := decisions[ask.Command] + go func() { + time.Sleep(100 * time.Millisecond) + _ = hitl.WriteAnswerIDOnce(slm, "shell", ask.ID, ShellAnswer{AskID: ask.ID, Decision: d}) + }() + } + var wg sync.WaitGroup + got := map[string]bool{} + var mu sync.Mutex + for cmd := range decisions { + wg.Add(1) + go func(cmd string) { + defer wg.Done() + ok, err := ws.waitShellApproval(context.Background(), cmd) + if err != nil { + t.Errorf("%s: %v", cmd, err) + } + mu.Lock() + got[cmd] = ok + mu.Unlock() + }(cmd) + } + wg.Wait() + if !got["go test ./a"] || got["rm -rf build"] { + t.Fatalf("decisions crossed: %v", got) + } +} diff --git a/pkg/workspace/syntax.go b/pkg/workspace/syntax.go index 8c698da..0eb330c 100644 --- a/pkg/workspace/syntax.go +++ b/pkg/workspace/syntax.go @@ -2,6 +2,7 @@ package workspace import ( "context" + "crypto/sha256" "fmt" "os" "os/exec" @@ -183,30 +184,92 @@ func (w *Workspace) applyWithSyntaxGuard(ctx context.Context, rel, abs, prev, ne return "", false, nil } // Establish the "before" verdict from the in-memory previous content so we - // never blame an edit for a break that was already there. + // never blame an edit for a break that was already there. The verdict is + // memoized per path by content hash: a worker editing the same file three + // times in a row used to spawn two checkers per edit (before + after), + // although the "before" of edit N+1 is byte-identical to the "after" of + // edit N. Now consecutive edits cost one checker each after the first. before := SyntaxResult{Status: SyntaxSkipped} if existed { - before = w.checkSyntaxOfText(ctx, abs, prev) + if memo, ok := w.syntaxMemoLookup(rel, prev); ok { + before = memo + } else { + before = w.checkSyntaxOfText(ctx, abs, prev) + w.syntaxMemoStore(rel, prev, before) + } } if err := os.WriteFile(abs, []byte(next), 0o644); err != nil { //nolint:gosec // project source file, conventional perms return "", false, err } - after := CheckSyntax(ctx, abs, w.syntaxTimeout()) + after := checkSyntaxFn(ctx, abs, w.syntaxTimeout()) if after.Status != SyntaxBroken { + w.syntaxMemoStore(rel, next, after) return "", false, nil } if before.Status == SyntaxOK { // Regression introduced by this edit → put the file back. if rerr := os.WriteFile(abs, []byte(prev), 0o644); rerr != nil { //nolint:gosec // project source file, conventional perms // Could not restore: keep the broken file but say so loudly. + w.syntaxMemoStore(rel, next, after) return SyntaxWarning(rel, after) + "\n(NOTE: automatic revert failed: " + rerr.Error() + ")", false, nil } + // File is back to prev, whose verdict is already memoized. return SyntaxRevertMessage(rel, after), true, nil } + w.syntaxMemoStore(rel, next, after) return SyntaxWarning(rel, after), false, nil } +// checkSyntaxFn is the checker the guard invokes. A variable so tests can +// count invocations with a fake instead of spawning gofmt/python/node. +var checkSyntaxFn = CheckSyntax + +// syntaxMemoEntry is one memoized verdict: the hash of the content it was +// computed for and the verdict itself. +type syntaxMemoEntry struct { + hash [32]byte + result SyntaxResult +} + +// maxSyntaxMemoPaths bounds the memo; a run touches a handful of files, and +// past that the oldest entries are simply dropped (worst case: one extra +// checker, exactly today's cost). +const maxSyntaxMemoPaths = 256 + +func (w *Workspace) syntaxMemoLookup(rel, content string) (SyntaxResult, bool) { + if w == nil { + return SyntaxResult{}, false + } + h := sha256.Sum256([]byte(content)) + w.syntaxMu.Lock() + defer w.syntaxMu.Unlock() + e, ok := w.syntaxMemo[rel] + if !ok || e.hash != h || e.result.Status == SyntaxSkipped { + return SyntaxResult{}, false + } + return e.result, true +} + +func (w *Workspace) syntaxMemoStore(rel, content string, res SyntaxResult) { + if w == nil || res.Status == SyntaxSkipped { + // A timed-out / unavailable checker is not a verdict worth keeping. + return + } + w.syntaxMu.Lock() + defer w.syntaxMu.Unlock() + if w.syntaxMemo == nil { + w.syntaxMemo = map[string]syntaxMemoEntry{} + } + if _, exists := w.syntaxMemo[rel]; !exists && len(w.syntaxMemo) >= maxSyntaxMemoPaths { + for k := range w.syntaxMemo { + delete(w.syntaxMemo, k) + break + } + } + w.syntaxMemo[rel] = syntaxMemoEntry{hash: sha256.Sum256([]byte(content)), result: res} +} + func (w *Workspace) syntaxTimeout() time.Duration { if w != nil && w.SyntaxCheckTimeout > 0 { return w.SyntaxCheckTimeout @@ -229,5 +292,5 @@ func (w *Workspace) checkSyntaxOfText(ctx context.Context, abs, text string) Syn if err := os.WriteFile(tmp, []byte(text), 0o600); err != nil { //nolint:gosec // tmp is our own os.MkdirTemp-generated private scratch path, not external input return SyntaxResult{Status: SyntaxSkipped} } - return CheckSyntax(ctx, tmp, w.syntaxTimeout()) + return checkSyntaxFn(ctx, tmp, w.syntaxTimeout()) } diff --git a/pkg/workspace/syntax_memo_test.go b/pkg/workspace/syntax_memo_test.go new file mode 100644 index 0000000..c399bd0 --- /dev/null +++ b/pkg/workspace/syntax_memo_test.go @@ -0,0 +1,80 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// Consecutive edits to one file used to spawn two checkers per edit (the +// "before" of edit N+1 is the "after" of edit N, re-parsed from scratch). The +// verdict is now memoized by content hash per path. +func TestSyntaxGuardMemoizesBeforeVerdict(t *testing.T) { + var calls atomic.Int32 + orig := checkSyntaxFn + checkSyntaxFn = func(_ context.Context, abs string, _ time.Duration) SyntaxResult { + calls.Add(1) + data, _ := os.ReadFile(abs) //nolint:gosec // test-owned temp path + if strings.Contains(string(data), "BROKEN") { + return SyntaxResult{Status: SyntaxBroken, Errors: "fake: broken", Tool: "fake"} + } + return SyntaxResult{Status: SyntaxOK, Tool: "fake"} + } + t.Cleanup(func() { checkSyntaxFn = orig }) + + w, root := newTestWS(t) + w.SyntaxCheck = true + ctx := context.Background() + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package a\n\nvar A = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + w.markRead("a.go") + + edit := func(oldS, newS string) string { + return strOut(w.editFile(ctx, map[string]interface{}{"path": "a.go", "old_str": oldS, "new_str": newS})) + } + if out := edit("var A = 1", "var A = 2"); !strings.HasPrefix(out, "edited a.go") { + t.Fatalf("edit 1: %q", out) + } + if got := calls.Load(); got != 2 { + t.Fatalf("first edit spawned %d checkers, want 2 (before + after)", got) + } + if out := edit("var A = 2", "var A = 3"); !strings.HasPrefix(out, "edited a.go") { + t.Fatalf("edit 2: %q", out) + } + if got := calls.Load(); got != 3 { + t.Fatalf("two consecutive edits spawned %d checkers, want 3 (the before-verdict was memoized)", got) + } + + // A revert leaves the file at prev, whose verdict is still memoized: the + // next good edit costs one checker again. + if out := edit("var A = 3", "BROKEN"); !strings.Contains(out, "EDIT REVERTED") { + t.Fatalf("broken edit not reverted: %q", out) + } + if got := calls.Load(); got != 4 { + t.Fatalf("reverted edit spawned %d checkers total, want 4", got) + } + if out := edit("var A = 3", "var A = 4"); !strings.HasPrefix(out, "edited a.go") { + t.Fatalf("edit after revert: %q", out) + } + if got := calls.Load(); got != 5 { + t.Fatalf("edit after revert spawned %d checkers total, want 5", got) + } + + // Content changed behind the memo (a shell command, another worker): the + // hash misses and the before-check runs again rather than trusting a + // stale verdict. + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("package a\n\nvar A = 40\n"), 0o644); err != nil { + t.Fatal(err) + } + if out := edit("var A = 40", "var A = 41"); !strings.HasPrefix(out, "edited a.go") { + t.Fatalf("edit after external change: %q", out) + } + if got := calls.Load(); got != 7 { + t.Fatalf("edit after external change spawned %d checkers total, want 7", got) + } +} diff --git a/pkg/workspace/tools.go b/pkg/workspace/tools.go index 5eed5b8..4ca695f 100644 --- a/pkg/workspace/tools.go +++ b/pkg/workspace/tools.go @@ -481,6 +481,12 @@ type Workspace struct { todoMu sync.Mutex todos []TodoItem + // syntaxMemo remembers the last syntax verdict per path, keyed by a hash + // of the content it was computed for, so the "before" check of the next + // edit to the same file is a lookup rather than a fourth checker process. + syntaxMu sync.Mutex + syntaxMemo map[string]syntaxMemoEntry + rootOnce sync.Once realRoot string @@ -578,7 +584,11 @@ func (w *Workspace) capResult(s string) string { len(s), max) } -func (w *Workspace) guardWrite(path, kind, content string) (string, bool, error) { +// guardWrite is the permission gate for a proposed change to path. In review +// mode the proposal is recorded to the queue — stamped with the task, role and +// query from ctx (see pending.go) — and from names the source of a ws_mv so +// the applier can move rather than copy. +func (w *Workspace) guardWrite(ctx context.Context, path, kind, content, from string) (string, bool, error) { switch permissions.Normalize(w.Permission) { case permissions.ModeDryRun: return fmt.Sprintf("dry-run: would %s %s (%d bytes)", kind, path, len(content)), true, nil @@ -586,7 +596,7 @@ func (w *Workspace) guardWrite(path, kind, content string) (string, bool, error) if w.SlmDir == "" { w.SlmDir = filepath.Join(w.Root, ".slmcode") } - p, err := permissions.RecordPending(w.SlmDir, path, kind, content) + p, err := w.recordPending(ctx, path, kind, content, from) if err != nil { return "", true, err } @@ -644,12 +654,23 @@ func (w *Workspace) resolvedRoot() string { } func (w *Workspace) checkSymlinkEscape(abs, rel string) error { + return w.checkSymlinkEscapeDepth(abs, rel, 0) +} + +// maxDanglingSymlinkHops bounds how many dangling links in a row are chased +// lexically before the path is refused outright. +const maxDanglingSymlinkHops = 8 + +func (w *Workspace) checkSymlinkEscapeDepth(abs, rel string, depth int) error { real := w.resolvedRoot() // Walk up to the deepest component that actually exists; components that // do not exist yet cannot be symlinks. p := abs + var info os.FileInfo for { - if _, err := os.Lstat(p); err == nil { + st, err := os.Lstat(p) + if err == nil { + info = st break } parent := filepath.Dir(p) @@ -660,8 +681,47 @@ func (w *Workspace) checkSymlinkEscape(abs, rel string) error { } evaluated, err := filepath.EvalSymlinks(p) if err != nil { - // Cannot evaluate (permissions, race) → fall back to the lexical check. - return nil + if info == nil || info.Mode()&os.ModeSymlink == 0 { + // Not a link and not evaluable (permissions, race) → the lexical + // check above is all there is. + return nil + } + // A DANGLING symlink: EvalSymlinks fails because the target does not + // exist yet, but os.WriteFile / os.Create would happily follow it and + // create that target — anywhere on the host. This used to be the one + // gap in the jail: `ln -s /elsewhere/victim.txt link.txt` followed by + // ws_write link.txt created /elsewhere/victim.txt. Resolve the link + // text ourselves and judge the target lexically; when the target sits + // inside the root it is re-checked in case it chains through another + // link. Anything that cannot be judged is refused — fail closed. + if depth >= maxDanglingSymlinkHops { + return fmt.Errorf( + "path escapes workspace via symlink: %s is a chain of unresolved symlinks. "+ + "Pick a path that names a real file inside the project", rel) + } + target, rerr := os.Readlink(p) + if rerr != nil { + return fmt.Errorf( + "path refused: %s is a symlink whose target cannot be resolved (%v). "+ + "Pick a path that names a real file inside the project", rel, rerr) + } + parent := filepath.Dir(p) + if pr, perr := filepath.EvalSymlinks(parent); perr == nil { + parent = pr + } + if !filepath.IsAbs(target) { + target = filepath.Join(parent, target) + } + target = filepath.Clean(target) + if target != real && !strings.HasPrefix(target, real+string(os.PathSeparator)) { + return fmt.Errorf( + "path escapes workspace via symlink: %s → %s (target does not exist yet). "+ + "The harness only operates on real files inside the project root; "+ + "pick a path that stays in the project", rel, target) + } + // The (missing) target is inside the root lexically; make sure no + // existing ancestor of it is itself a link out of the jail. + return w.checkSymlinkEscapeDepth(target, rel, depth+1) } if evaluated == real || strings.HasPrefix(evaluated, real+string(os.PathSeparator)) { return nil @@ -809,7 +869,7 @@ func readErrorHint(path string, err error) string { func (w *Workspace) writeFile(ctx context.Context, args map[string]interface{}) (interface{}, error) { path := w.normalizeRelPath(strArg(args, "path")) - content := strArg(args, "content") + content := strArgAny(args, contentKeys...) allowShrink := boolArg(args, "allow_shrink", false) if strings.TrimSpace(path) == "" { return "ws_write: path is required, e.g. {\"path\":\"pkg/foo/bar.go\",\"content\":\"…\"}.", nil @@ -826,12 +886,20 @@ func (w *Workspace) writeFile(ctx context.Context, args map[string]interface{}) // of `prev` and the write derived from it. defer w.lockPath(path)() prev, existed := readIfExists(abs) + // An empty body on a NEW file is, in practice, never the intent — it is a + // model that put the text under a key this tool does not read. Writing a + // 0-byte file and reporting "wrote" sent the loop on to the next step with + // an empty source file in the tree. The same trap truncates an existing + // file, so that case needs the explicit allow_shrink escape hatch. + if content == "" && (!existed || !allowShrink) { + return EmptyContentReason(path, existed, args), nil + } if w.WriteGuard { if refuse, reason := w.checkOverwrite(path, abs, existed, prev, content, allowShrink); refuse { return reason, nil } } - if msg, stop, err := w.guardWrite(path, "write", content); stop { + if msg, stop, err := w.guardWrite(ctx, path, "write", content, ""); stop { kind := "review" if strings.HasPrefix(msg, "dry-run:") { kind = "dry-run" @@ -897,10 +965,10 @@ func readIfExists(abs string) (string, bool) { return string(data), true } -func (w *Workspace) editFile(ctx context.Context, args map[string]interface{}) (interface{}, error) { +func (w *Workspace) editFile(ctx context.Context, args map[string]interface{}) (out interface{}, err error) { path := w.normalizeRelPath(strArg(args, "path")) - oldStr := strArg(args, "old_str") - newStr := strArg(args, "new_str") + oldStr := strArgAny(args, oldStrKeys...) + newStr := strArgAny(args, newStrKeys...) replaceAll := boolArg(args, "replace_all", false) if strings.TrimSpace(path) == "" { return "ws_edit: path is required, e.g. {\"path\":\"pkg/foo/bar.go\",\"old_str\":\"…\",\"new_str\":\"…\"}.", nil @@ -911,9 +979,31 @@ func (w *Workspace) editFile(ctx context.Context, args map[string]interface{}) ( if strings.TrimSpace(oldStr) == "" { return EmptyOldStrReason(path), nil } + // Text pasted straight out of a ws_read result carries the " 42|" + // gutter. When EVERY non-blank line carries it the intent is unambiguous, + // so the gutter is stripped and the edit proceeds; the note stays on the + // result so evolve still sees the drift and can learn the rule. A partial + // gutter is still refused — that is not a paste, that is confusion. + var gutterNote string if lineNumberPrefixRe.MatchString(oldStr) { - return LineNumberedOldStrReason(path), nil + stripped, ok := stripReadGutter(oldStr) + if !ok { + return LineNumberedOldStrReason(path), nil + } + oldStr = stripped + if ns, ok := stripReadGutter(newStr); ok { + newStr = ns + } + gutterNote = GutterStrippedNote + if strings.TrimSpace(oldStr) == "" { + return EmptyOldStrReason(path), nil + } } + defer func() { + if s, ok := out.(string); ok && gutterNote != "" { + out = s + gutterNote + } + }() abs, err := w.resolve(path) if err != nil { return nil, err @@ -959,6 +1049,16 @@ func (w *Workspace) editFile(ctx context.Context, args map[string]interface{}) ( exact, path, ) + augment.FailureRecovery("ws_edit", path), nil default: + // replace_all with a drifted old_str: every span the first matching + // rung finds is replaced. Honoring the flag only on an exact hit + // meant "found 3 places (indentation-normalized) — ambiguous" for a + // caller who had explicitly asked for all of them. + if replaceAll { + if drifted, n, via := ReplaceAllDrifted(text, oldStr, newStr); n > 0 { + next, count, strategy = drifted, n, via + break + } + } // Exact match missed: run the tolerant fallback ladder. res := FindEditMatch(text, oldStr) if !res.Found { @@ -977,7 +1077,7 @@ func (w *Workspace) editFile(ctx context.Context, args map[string]interface{}) ( } snippet := diffSnippet(oldStr, newStr) - if msg, stop, err := w.guardWrite(path, "edit", next); stop { + if msg, stop, err := w.guardWrite(ctx, path, "edit", next, ""); stop { if msg != "" && strings.HasPrefix(msg, "dry-run:") { out := fmt.Sprintf("dry-run: would edit %s (%d replacement(s))", path, count) w.notify(path, "dry-run", snippet) @@ -1028,9 +1128,105 @@ func LineNumberedOldStrReason(path string) string { path) } -func (w *Workspace) patchFile(ctx context.Context, args map[string]interface{}) (interface{}, error) { +// GutterStrippedNote is appended to a ws_edit / ws_patch result whose search +// text arrived with ws_read's line-number gutter on every line. The edit went +// ahead; the note is what pkg/evolve keys its strip_line_number_prefix rule on. +const GutterStrippedNote = "\n[stripped ws_read line numbers from old_str — do not include them]" + +// gutterLineRe is lineNumberPrefixRe anchored for one line, capturing the text +// after the "|" so the original indentation survives. +var gutterLineRe = regexp.MustCompile(`^\s*\d+\|(.*)$`) + +// stripReadGutter removes the " 42|" prefix from every line of s. It reports +// ok=false when any non-blank line lacks the gutter — a mixed block is not a +// paste from ws_read and is left for the caller to refuse by name. +func stripReadGutter(s string) (string, bool) { + if s == "" { + return s, false + } + lines := strings.Split(s, "\n") + for i, ln := range lines { + if strings.TrimSpace(ln) == "" { + continue + } + m := gutterLineRe.FindStringSubmatch(ln) + if m == nil { + return s, false + } + lines[i] = m[1] + } + return strings.Join(lines, "\n"), true +} + +// stripReadGutterPatch is stripReadGutter for ws_patch input. Marker lines +// (<<<<<<<, =======, >>>>>>>, @@) never carry a gutter and are skipped; every +// other non-blank line must, or the whole text is left alone (ok=false). A +// unified-diff "+"/"-" sign in front of the gutter is kept; a space sign is +// indistinguishable from the gutter's own padding and simply becomes an +// unprefixed context line, which the hunk parser already accepts. +func stripReadGutterPatch(s string) (string, bool) { + lines := strings.Split(s, "\n") + for i, ln := range lines { + t := strings.TrimSpace(ln) + if t == "" || strings.HasPrefix(t, "<<<<<<<") || strings.HasPrefix(t, "=======") || + strings.HasPrefix(t, ">>>>>>>") || strings.HasPrefix(t, "@@") { + continue + } + sign, rest := "", ln + if strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "-") { + sign, rest = ln[:1], ln[1:] + } + m := gutterLineRe.FindStringSubmatch(rest) + if m == nil { + return s, false + } + lines[i] = sign + m[1] + } + return strings.Join(lines, "\n"), true +} + +// EmptyContentReason explains a refused empty ws_write and names every key the +// tool reads for the body, since the usual cause is the text sitting under a +// key that was never looked at. +func EmptyContentReason(path string, existed bool, args map[string]interface{}) string { + var extra []string + for k := range args { + switch k { + case "path", "allow_shrink": + continue + } + known := false + for _, c := range contentKeys { + if k == c { + known = true + break + } + } + if !known { + extra = append(extra, k) + } + } + sort.Strings(extra) + msg := fmt.Sprintf( + "Write refused — content is empty, so this would create %s as a 0-byte file.\n"+ + "ws_write reads the file body from one of: %s. ", path, aliasList(contentKeys)) + if existed { + msg = fmt.Sprintf( + "Write refused — content is empty, so this would truncate %s to 0 bytes.\n"+ + "ws_write reads the file body from one of: %s. "+ + "To really empty the file, repeat the call with \"allow_shrink\": true. ", + path, aliasList(contentKeys)) + } + if len(extra) > 0 { + msg += fmt.Sprintf("Unrecognized argument(s) in this call: %s. ", strings.Join(extra, ", ")) + } + msg += "Repeat the call with the full file text under \"content\"." + return msg +} + +func (w *Workspace) patchFile(ctx context.Context, args map[string]interface{}) (out interface{}, err error) { path := w.normalizeRelPath(strArg(args, "path")) - patch := strArg(args, "patch") + patch := strArgAny(args, patchKeys...) if strings.TrimSpace(path) == "" { return "ws_patch: path is required, e.g. {\"path\":\"pkg/foo/bar.go\",\"patch\":\"@@ …\"}.", nil } @@ -1038,9 +1234,23 @@ func (w *Workspace) patchFile(ctx context.Context, args map[string]interface{}) return "ws_patch: patch is required — supply a unified diff with @@ hunks, or a " + "<<<<<<< SEARCH / ======= / >>>>>>> REPLACE block.", nil } + // Same rule as ws_edit: a ws_read gutter on every content line is + // stripped (marker and @@ lines are exempt from the "every line" test); + // anything less uniform is refused by name. + var gutterNote string if lineNumberPrefixRe.MatchString(patch) { - return LineNumberedOldStrReason(path), nil + stripped, ok := stripReadGutterPatch(patch) + if !ok { + return LineNumberedOldStrReason(path), nil + } + patch = stripped + gutterNote = GutterStrippedNote } + defer func() { + if s, ok := out.(string); ok && gutterNote != "" { + out = s + gutterNote + } + }() abs, err := w.resolve(path) if err != nil { return nil, err @@ -1076,7 +1286,7 @@ func (w *Workspace) patchFile(ctx context.Context, args map[string]interface{}) } } } - if msg, stop, err := w.guardWrite(path, "patch", next); stop { + if msg, stop, err := w.guardWrite(ctx, path, "patch", next, ""); stop { kind := "review" if strings.HasPrefix(msg, "dry-run:") { kind = "dry-run" @@ -1157,7 +1367,7 @@ func (w *Workspace) moveFile(ctx context.Context, args map[string]interface{}) ( if readErr != nil { return fmt.Sprintf("ws_mv: cannot read %s: %v. Fix the path or permissions, then retry.", from, readErr), nil } - if msg, stop, err := w.guardWrite(to, "mv", string(content)); stop { + if msg, stop, err := w.guardWrite(ctx, to, "mv", string(content), from); stop { kind := "review" if strings.HasPrefix(msg, "dry-run:") { kind = "dry-run" @@ -1239,7 +1449,7 @@ func (w *Workspace) deleteFile(ctx context.Context, args map[string]interface{}) return fmt.Sprintf( "ws_delete: %s is a directory. This tool deletes single files only.", path), nil } - if msg, stop, err := w.guardWrite(path, "delete", ""); stop { + if msg, stop, err := w.guardWrite(ctx, path, "delete", "", ""); stop { kind := "review" if strings.HasPrefix(msg, "dry-run:") { kind = "dry-run" @@ -1807,17 +2017,20 @@ func (w *Workspace) waitShellApproval(ctx context.Context, command string) (bool OnTimeout: "deny", CreatedAt: time.Now().UTC().Format(time.RFC3339), } - if err := hitl.WriteAsk(w.SlmDir, "shell", ask); err != nil { + // One file per ask (hitl.WriteAskID): parallel workers each wait on their + // own answer instead of sharing — and clobbering — a single slot. The + // command is deliberately NOT mirrored into the review queue any more: a + // queue entry is a file write, and `slmcode apply --all` used to land a + // literal shell.sh at the project root for every ask. + if err := hitl.WriteAskID(w.SlmDir, "shell", ask.ID, ask); err != nil { return false, err } - // Also record classic pending for apply CLI visibility. - _, _ = permissions.RecordPending(w.SlmDir, "shell.sh", "shell", command) if w.OnShellAsk != nil { w.OnShellAsk(ask) } var ans ShellAnswer - ok, err := hitl.WaitAnswersForID(ctx, w.SlmDir, "shell", ask.ID, timeout, &ans) - hitl.Clear(w.SlmDir, "shell") + ok, err := hitl.WaitAnswerID(ctx, w.SlmDir, "shell", ask.ID, timeout, &ans) + hitl.ClearID(w.SlmDir, "shell", ask.ID) if err != nil { return false, err } diff --git a/pkg/workspace/tools_hardening_test.go b/pkg/workspace/tools_hardening_test.go index 9532c86..fcc35e4 100644 --- a/pkg/workspace/tools_hardening_test.go +++ b/pkg/workspace/tools_hardening_test.go @@ -87,20 +87,34 @@ func TestAssessOverEditDoesNotMaskEmptyOldStr(t *testing.T) { // ── item 11: line-number prefixes ────────────────────────────────────────── +// A gutter on EVERY line is a paste from ws_read: the edit proceeds with the +// gutter stripped and the note appended (see TestEditStripsGutterAutomatically). +// A gutter on only SOME lines is still refused by name. func TestEditRejectsLineNumberedOldStr(t *testing.T) { - cases := []struct{ name, oldStr string }{ - {"single line", " 3|func F() {}"}, - {"multi line", " 1|package a\n 2|"}, - {"no padding", "3|func F() {}"}, + cases := []struct { + name, oldStr string + stripped bool + }{ + {"single line", " 3|func F() {}", true}, + {"multi line", " 1|package a\n 2|", true}, + {"no padding", "3|func F() {}", true}, + {"mixed", " 3|func F() {}\nfunc F() {}", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w, root := newTestWS(t) + w.SyntaxCheck = false mustWrite(t, filepath.Join(root, "a.go"), "package a\n\nfunc F() {}\n") w.Reads.Mark("a.go") out := strOut(w.editFile(context.Background(), map[string]interface{}{ "path": "a.go", "old_str": tc.oldStr, "new_str": "func G() {}", })) + if tc.stripped { + if !strings.Contains(out, "[stripped ws_read line numbers") { + t.Fatalf("expected gutter to be stripped with a note, got %q", out) + } + return + } if !strings.Contains(out, "line-number prefix") { t.Fatalf("expected line-number diagnosis, got %q", out) } diff --git a/web/src/App.tsx b/web/src/App.tsx index de9cd2d..07e52e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,8 @@ import { Routes, Route } from 'react-router-dom'; import Layout from './components/Layout'; import { getHealth, getConfig, errorText } from './api/client'; import { useLiveStream } from './hooks/useLiveStream'; +import { BoardStoreProvider, useBoardStoreSource } from './hooks/useBoardStore'; +import type { RunDerived } from './hooks/runDerived'; import { ToastProvider } from './components/ui/Toast'; import { ConfirmProvider } from './components/ui/Modal'; import ErrorBoundary from './components/ui/ErrorBoundary'; @@ -46,6 +48,13 @@ export interface AppContextValue { askSignal: number; /** Accumulated token deltas for the active agent turn. */ tokenStream: string; + /** What the log adds up to, folded once per event — see hooks/runDerived. */ + liveDerived: RunDerived; + /** + * Changes awaiting review: from the stream's health poll and the server's + * `review_pending` event. Null until either has answered. + */ + pendingReview: number | null; } export const AppContext = React.createContext(null); @@ -133,13 +142,27 @@ function AppInner() { } }, []); + // The board store is owned here too, beside the stream that feeds it: the + // stream hands it every task_update, and it hands the pages one live board + // instead of four pollers. See hooks/useBoardStore. + const boardEventRef = useRef<(ev: RunEvent) => void>(() => {}); + const onBoardEvent = useCallback((ev: RunEvent) => boardEventRef.current(ev), []); + const initialEventsRef = useRef(readStoredEvents()); const stream = useLiveStream({ initialEvents: initialEventsRef.current, initialRunning: readStoredRunning(), onEvents: persistEvents, onRunning: persistRunning, + onBoardEvent, + }); + + const board = useBoardStoreSource({ + connection: stream.connection, + running: stream.running, + lastEvent: stream.derived.last, }); + boardEventRef.current = board.applyEvent; useEffect(() => { if (stream.latest) setLiveResult(stream.latest); @@ -204,6 +227,8 @@ function AppInner() { clearStreamGap: stream.clearGap, askSignal: stream.askSignal, tokenStream: stream.tokenStream, + liveDerived: stream.derived, + pendingReview: stream.pendingReview, }), [ health, @@ -223,11 +248,14 @@ function AppInner() { stream.clearGap, stream.askSignal, stream.tokenStream, + stream.derived, + stream.pendingReview, ], ); return ( + }> @@ -248,6 +276,7 @@ function AppInner() { + ); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d97d1b3..e1e251a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -293,6 +293,11 @@ export async function patchTask(id: string, patch: Partial): Promise }); } +// POST /api/tasks/{id}/retry → the task, back on the board for another attempt +export async function retryTask(id: string): Promise { + return request(`/tasks/${encodeURIComponent(id)}/retry`, { method: 'POST' }); +} + // DELETE /api/tasks/{id} → {ok: "true"} export async function deleteTask(id: string): Promise<{ ok: string }> { return request(`/tasks/${encodeURIComponent(id)}`, { diff --git a/web/src/components/Agents/AgentManager.tsx b/web/src/components/Agents/AgentManager.tsx index 7d8d835..371e9f4 100644 --- a/web/src/components/Agents/AgentManager.tsx +++ b/web/src/components/Agents/AgentManager.tsx @@ -15,6 +15,7 @@ import { import clsx from 'clsx'; import { useConfirm } from '@/components/ui/Modal'; import { useToast } from '@/components/ui/Toast'; +import ErrorState from '@/components/ui/ErrorState'; const EMPTY_AGENT: AgentSpec = { id: '', @@ -36,6 +37,7 @@ export default function AgentManager() { const toast = useToast(); const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); @@ -48,8 +50,11 @@ export default function AgentManager() { try { const list = await getAgents(); setAgents(list); + setLoadError(null); } catch (e) { - console.error('Failed to load agents:', e); + // Shown in place of the list with a Retry; an empty list over a dead + // backend used to read as "no custom agents defined yet". + setLoadError(e); } finally { setLoading(false); } @@ -64,11 +69,11 @@ export default function AgentManager() { getSkills() .then((list) => setAllSkills(list || [])) .catch((e) => { - console.error('Failed to load skills:', e); + toast.reportError(e, 'Could not load the skill list for the picker'); setAllSkills([]); }) .finally(() => setSkillsLoaded(true)); - }, []); + }, [toast]); const toggleSkill = useCallback((name: string) => { setForm((f) => { @@ -104,8 +109,13 @@ export default function AgentManager() { const detail = await getAgent(agent.id); setForm({ ...detail }); } catch (e) { - console.error('Failed to load agent detail:', e); - // Fall back to list data (prompts may be missing) + // Fall back to list data (prompts may be missing) — and say so, or a + // save from this form silently blanks the system prompt. + toast.push({ + tone: 'warning', + title: `Editing ${agent.title || agent.id} from the summary only`, + detail: `The full spec could not be loaded (${e instanceof Error ? e.message : String(e)}); prompts may be missing.`, + }); setForm({ ...agent }); } finally { setDetailLoading(false); @@ -520,11 +530,20 @@ export default function AgentManager() { ))} - {agents.length === 0 && !showForm && ( + {loadError !== null && !showForm && } + + {!loadError && agents.length === 0 && !showForm && (
-

No custom agents defined yet.

-

Built-in specialists are always available.

+

No agents yet.

+

+ An agent is a role with its own prompt, model and skills — the pipeline seats one per phase. Built-in + specialists are always available; add one here to override a builtin or introduce a new role. +

+
)} diff --git a/web/src/components/Blocks/BlockManager.tsx b/web/src/components/Blocks/BlockManager.tsx index 54004ef..bd24cbf 100644 --- a/web/src/components/Blocks/BlockManager.tsx +++ b/web/src/components/Blocks/BlockManager.tsx @@ -19,6 +19,7 @@ import { import clsx from 'clsx'; import BlockEditor from './BlockEditor'; import { useConfirm } from '@/components/ui/Modal'; +import { useRovingTabs } from '@/components/ui/useRovingTabs'; const KIND_ICONS: Record = { pack: , @@ -164,6 +165,13 @@ export default function BlockManager() { }; const blocks = getBlocksForTab(); + const selectTab = useCallback((id: string) => { + setActiveTab(id); + setLoading(true); + }, []); + // 'all' stands in for the empty id: ids become element ids, which cannot be empty. + const tabs = TABS.map((t) => ({ id: t.id || 'all' })); + const { tabProps, panelProps, listProps } = useRovingTabs(tabs, activeTab || 'all', (id) => selectTab(id === 'all' ? '' : id)); return (
@@ -194,13 +202,13 @@ export default function BlockManager() {
{/* Tabs */} -
+
{TABS.map((tab) => (
+
+ {/* Notice */} {notice && (
@@ -238,12 +248,28 @@ export default function BlockManager() { {!loading && blocks.length === 0 && (
-

No blocks found

-

- {activeTab - ? `Add YAML blocks to .slmcode/blocks/ or click "New ${KIND_TITLES[activeTab]}" above` - : 'Add YAML blocks to .slmcode/blocks/ or ~/.slmcode/blocks/'} +

+ {activeTab ? `No ${KIND_TITLES[activeTab].toLowerCase()} blocks yet` : 'No blocks yet'} +

+

+ Blocks are the YAML presets a run is assembled from: a pack picks a stack, a pipeline orders the + phases, agents fill the seats and quality blocks gate the work. They live in{' '} + .slmcode/blocks/ (project) or ~/.slmcode/blocks/.

+ {activeTab ? ( + + ) : ( + + )}
)} @@ -392,6 +418,7 @@ export default function BlockManager() { ))}
)} +
{/* Create/Edit modal */} {editor && ( diff --git a/web/src/components/Board/KanbanBoard.tsx b/web/src/components/Board/KanbanBoard.tsx index ae3eb54..d9e37d0 100644 --- a/web/src/components/Board/KanbanBoard.tsx +++ b/web/src/components/Board/KanbanBoard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { DndContext, DragOverlay, @@ -10,25 +11,15 @@ import { type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { getBoard, getSquads, patchTask, addTask } from '@/api/client'; -import type { Board as BoardType, SquadsView, Task } from '@/types'; +import { patchTask, addTask } from '@/api/client'; +import type { Task } from '@/types'; +import { useBoardStore } from '@/hooks/useBoardStore'; +import { COLUMN_LABELS, COLUMN_ORDER, columnLabel } from '@/components/shared/labels'; import TaskCard from './TaskCard'; import TeamStrip, { UNASSIGNED_FILTER } from './TeamStrip'; import clsx from 'clsx'; import { Loader2, Plus, RefreshCw, X } from 'lucide-react'; -const COLUMN_LABELS: Record = { - to_scope: 'To Scope', - scoped: 'Scoped', - ready_to_dev: 'Ready', - in_progress: 'In Progress', - in_review: 'In Review', - blocked: 'Blocked', - done: 'Done', -}; - -const COLUMN_ORDER = ['to_scope', 'scoped', 'ready_to_dev', 'in_progress', 'in_review', 'blocked', 'done']; - interface NewTaskDraft { title: string; description: string; @@ -50,96 +41,84 @@ const EMPTY_DRAFT: NewTaskDraft = { }; export default function KanbanBoard() { - const [board, setBoard] = useState(null); + // The board and the org chart come from the shared store — seeded once, + // kept current by the stream's task_update events, re-read every 30 s as a + // safety net. This page used to poll both every 3 s for as long as it was + // open. See hooks/useBoardStore. + const board = useBoardStore(); + const { tasks: allTasks, squads, refresh: fetchBoard, refreshing, upsertTask } = board; + const loading = !board.ready; const [activeTask, setActiveTask] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); const [showAdd, setShowAdd] = useState(false); const [draft, setDraft] = useState(EMPTY_DRAFT); const [savingAdd, setSavingAdd] = useState(false); const [addError, setAddError] = useState(''); - // The org chart this board is executing against. Null on a single-stream run, - // which is most of them — the strip hides itself rather than showing an empty - // header nobody can act on. - const [squads, setSquads] = useState(null); - // The org chart's team ids, so a card can be assigned to one. + // The org chart's team ids, so a card can be assigned to one. Null on a + // single-stream run, which is most of them — the strip hides itself rather + // than showing an empty header nobody can act on. const teamIDs = useMemo(() => (squads?.ok ? (squads.squads ?? []).map((s) => s.id) : []), [squads]); - const [teamFilter, setTeamFilter] = useState(''); + // Both filters live in the URL (?team, ?column), so a filtered board can be + // linked to and survives a reload. + const [searchParams, setSearchParams] = useSearchParams(); + const teamFilter = searchParams.get('team') ?? ''; + const columnFilter = searchParams.get('column') ?? ''; + const setParam = useCallback( + (key: 'team' | 'column', value: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + const setTeamFilter = useCallback((v: string) => setParam('team', v), [setParam]); + const setColumnFilter = useCallback((v: string) => setParam('column', v), [setParam]); const addTitleRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); - const fetchBoard = useCallback(async () => { - try { - setRefreshing(true); - const b = await getBoard(); - setBoard(b); - } catch (e) { - console.error('Failed to load board:', e); - } finally { - setLoading(false); - setRefreshing(false); - } - }, []); - useEffect(() => { if (showAdd) { addTitleRef.current?.focus(); } }, [showAdd]); - // The org chart moves far more slowly than the board — it is written once per - // run — so it rides the same poll rather than getting its own timer. - const fetchSquads = useCallback(async () => { - try { - setSquads(await getSquads()); - } catch { - // No org chart is the normal state, not an error. - setSquads(null); - } - }, []); - - useEffect(() => { - fetchBoard(); - void fetchSquads(); - const interval = setInterval(() => { - fetchBoard(); - void fetchSquads(); - }, 3000); - return () => clearInterval(interval); - }, [fetchBoard, fetchSquads]); - const columns = useMemo(() => { - const fromBoard = board?.columns?.length ? board.columns : []; + const fromBoard = board.columns.length ? board.columns : []; return [...new Set([...COLUMN_ORDER, ...fromBoard])]; - }, [board?.columns]); + }, [board.columns]); + const shownColumns = useMemo( + () => (columnFilter && columns.includes(columnFilter) ? [columnFilter] : columns), + [columns, columnFilter], + ); // Filtering happens BEFORE grouping, so a filtered column shows its own // count rather than the whole board's — "the backend's four, and they are all // blocked" is the sentence this page exists to make readable. const visibleTasks = useMemo(() => { - const all = board?.tasks || []; - if (teamFilter === '') return all; - if (teamFilter === UNASSIGNED_FILTER) return all.filter((t) => !t.squad); - return all.filter((t) => t.squad === teamFilter); - }, [board?.tasks, teamFilter]); + if (teamFilter === '') return allTasks; + if (teamFilter === UNASSIGNED_FILTER) return allTasks.filter((t) => !t.squad); + return allTasks.filter((t) => t.squad === teamFilter); + }, [allTasks, teamFilter]); const byColumn = useMemo(() => groupTasks(visibleTasks, columns), [visibleTasks, columns]); - const totalTasks = board?.tasks?.length || 0; + const totalTasks = allTasks.length; const handleDragStart = (event: DragStartEvent) => { const taskId = event.active.id as string; - if (board) { - const task = board.tasks.find((t) => t.id === taskId); - setActiveTask(task || null); - } + setActiveTask(allTasks.find((t) => t.id === taskId) || null); }; const handleDragEnd = async (event: DragEndEvent) => { setActiveTask(null); const { active, over } = event; - if (!over || !board) return; + if (!over) return; const taskId = active.id as string; const overId = over.id as string; @@ -149,24 +128,22 @@ export default function KanbanBoard() { if (columns.includes(overId)) { targetCol = overId; } else { - const overTask = board.tasks.find((t) => t.id === overId); + const overTask = allTasks.find((t) => t.id === overId); if (!overTask) return; targetCol = overTask.column; } - const task = board.tasks.find((t) => t.id === taskId); + const task = allTasks.find((t) => t.id === taskId); if (!task || task.column === targetCol) return; - // Optimistic update - const newTasks = board.tasks.map((t) => - t.id === taskId ? { ...t, column: targetCol } : t, - ); - setBoard({ ...board, tasks: newTasks, by_column: groupTasks(newTasks, columns) }); - + // Optimistic: paint the move now; the server's task_update (or the + // refresh on failure) is the truth. + upsertTask({ ...task, column: targetCol }); try { - await patchTask(taskId, { column: targetCol }); + const saved = await patchTask(taskId, { column: targetCol }); + if (saved && saved.id) upsertTask(saved); } catch { - fetchBoard(); // revert + void fetchBoard(); // revert } }; @@ -195,10 +172,7 @@ export default function KanbanBoard() { acceptance: draft.acceptance.trim(), files: splitList(draft.files), }); - if (board) { - const tasks = [created, ...(board.tasks || [])]; - setBoard({ ...board, tasks, by_column: groupTasks(tasks, columns) }); - } + if (created && created.id) upsertTask(created); setShowAdd(false); setDraft(EMPTY_DRAFT); await fetchBoard(); @@ -220,12 +194,15 @@ export default function KanbanBoard() { ); } - if (!board) { + if (board.error && totalTasks === 0) { return (

No board data yet.

-

Run a query to populate the kanban board.

+

{board.error}

+
); @@ -243,6 +220,17 @@ export default function KanbanBoard() { {visibleTasks.length} in {teamFilter === UNASSIGNED_FILTER ? 'no team' : teamFilter} )} + {columnFilter !== '' && ( + + )}
{board.plan?.summary && (

@@ -252,7 +240,7 @@ export default function KanbanBoard() {

{tasks.length} @@ -430,7 +426,7 @@ export default function KanbanBoard() { >
{tasks.map((task) => ( - + void fetchBoard()} /> ))} {tasks.length === 0 && (
@@ -447,7 +443,7 @@ export default function KanbanBoard() { {activeTask && (
- + void fetchBoard()} isDragOverlay />
)}
diff --git a/web/src/components/Board/TaskCard.test.tsx b/web/src/components/Board/TaskCard.test.tsx new file mode 100644 index 0000000..911ccec --- /dev/null +++ b/web/src/components/Board/TaskCard.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import TaskCard from './TaskCard'; +import type { Task } from '@/types'; + +const retryTask = vi.fn(async (id: string) => ({ id }) as unknown as Task); +const patchTask = vi.fn(async (id: string, patch: Partial) => ({ id, ...patch }) as Task); + +vi.mock('@/api/client', () => { + class ApiError extends Error { + status: number; + constructor(status: number, body: string) { + super(body); + this.status = status; + } + get isConflict() { + return this.status === 409; + } + } + return { + ApiError, + errorText: (e: unknown) => (e instanceof Error ? e.message : String(e)), + retryTask: (...a: unknown[]) => retryTask(...(a as [string])), + patchTask: (...a: unknown[]) => patchTask(...(a as [string, Partial])), + deleteTask: vi.fn(async () => ({ ok: 'true' })), + }; +}); + +const stuck: Task = { + id: 'T3', + title: 'Wire the retry endpoint', + description: '', + role: 'worker', + assignee: '', + column: 'blocked', + status: 'blocked', + priority: 0, + depends_on: [], + files: ['pkg/server/tasks.go'], + acceptance: '', + checklist: [], + output: '', + review: 'Reviewer: the handler returns 200 on an unknown id.', + retries: 2, + error: 'attempt 2 failed because go test ./pkg/server timed out', + updated_at: '', + notes: '', + attempt_log: [ + 'attempt 1 failed because handler not registered on the mux', + 'attempt 2 failed because go test ./pkg/server timed out', + ], + gate_retries: 1, + criteria: [ + { id: 'AC1', text: 'POST /api/tasks/{id}/retry returns 404 for an unknown id', met: false }, + { id: 'AC2', text: 'Attempt log survives the retry', priority: 'should' }, + ], +}; + +function renderCard(task: Task, onUpdate = vi.fn()) { + render( + + + , + ); + return onUpdate; +} + +describe('TaskCard — the story of a stuck task', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the attempts timeline, the verdict and the criteria once expanded', async () => { + renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + + const timeline = screen.getByTestId('attempts-timeline'); + expect(timeline).toHaveTextContent('Attempts'); + expect(timeline).toHaveTextContent('2 failed'); + expect(timeline).toHaveTextContent('1 gate retry'); + // Each attempt is numbered and its reason is shown without the boilerplate. + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('handler not registered on the mux')).toBeInTheDocument(); + expect(screen.getByText('go test ./pkg/server timed out')).toBeInTheDocument(); + + expect(screen.getByText('Review verdict')).toBeInTheDocument(); + expect(screen.getByText(/returns 200 on an unknown id/)).toBeInTheDocument(); + + expect(screen.getByText('Criteria')).toBeInTheDocument(); + expect(screen.getByText(/returns 404 for an unknown id/)).toBeInTheDocument(); + expect(screen.getByText('not met:')).toBeInTheDocument(); + expect(screen.getByText('not yet checked:')).toBeInTheDocument(); + }); + + it('offers the next step: Retry calls the endpoint and refreshes the board', async () => { + const onUpdate = renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + + const group = screen.getByRole('group', { name: 'Next steps for T3' }); + expect(group).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Retry$/ })); + + await waitFor(() => expect(retryTask).toHaveBeenCalledWith('T3')); + expect(onUpdate).toHaveBeenCalled(); + }); + + it('sends the task back to Ready through the board patch', async () => { + const onUpdate = renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + await userEvent.click(screen.getByRole('button', { name: /Send back to Ready/ })); + await waitFor(() => + expect(patchTask).toHaveBeenCalledWith('T3', expect.objectContaining({ column: 'ready_to_dev' })), + ); + expect(onUpdate).toHaveBeenCalled(); + }); + + it('opens the edit form on the team control for Reassign', async () => { + renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + await userEvent.click(screen.getByRole('button', { name: /Reassign team/ })); + const select = await screen.findByLabelText('Team of T3'); + await waitFor(() => expect(select).toHaveFocus()); + }); + + it('keeps the action row off a healthy task', async () => { + renderCard({ ...stuck, column: 'done', status: 'done', attempt_log: [], error: '' }); + await userEvent.click(screen.getByTitle('Show full task details')); + expect(screen.queryByRole('group', { name: /Next steps/ })).not.toBeInTheDocument(); + expect(screen.queryByTestId('attempts-timeline')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Board/TaskCard.tsx b/web/src/components/Board/TaskCard.tsx index 82ec3d7..fdd9592 100644 --- a/web/src/components/Board/TaskCard.tsx +++ b/web/src/components/Board/TaskCard.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { patchTask, deleteTask } from '@/api/client'; import { teamColor } from './teamColor'; +import { AttemptsTimeline, CriteriaChecklist, ReviewVerdict, TaskActionRow, isStuck } from './TaskStory'; import type { Task } from '@/types'; import clsx from 'clsx'; import { useConfirm } from '@/components/ui/Modal'; @@ -49,9 +50,28 @@ interface TaskDraft { notes: string; } +// Colour plus a word: the ring around a failed card and a lone red triangle +// read as decoration to anyone who cannot see red, so each state also says +// its name. const STATUS_ICON: Record = { - done: , - failed: , + done: ( + + + ), + failed: ( + + + ), + blocked: ( + + + ), }; const STATUS_OPTIONS = ['todo', 'scoped', 'ready', 'running', 'review', 'correcting', 'blocked', 'failed', 'done']; @@ -64,6 +84,10 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp const [saving, setSaving] = useState(false); const [draft, setDraft] = useState(() => taskToDraft(task)); const titleFieldRef = useRef(null); + const teamFieldRef = useRef(null); + // "Reassign team" opens the edit form on the team control rather than the + // title. A ref, not state: the flag must not re-run the focus effect. + const focusTeamRef = useRef(false); const ticket = ticketInfo(task); useEffect(() => { @@ -71,11 +95,22 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp }, [task]); useEffect(() => { - if (editing) { - titleFieldRef.current?.focus(); + if (!editing) return; + const wantTeam = focusTeamRef.current; + focusTeamRef.current = false; + if (wantTeam && teamFieldRef.current) { + teamFieldRef.current.focus(); + return; } + titleFieldRef.current?.focus(); }, [editing]); + const handleReassign = () => { + focusTeamRef.current = true; + setEditing(true); + setExpanded(true); + }; + const { attributes, listeners, @@ -354,6 +389,7 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp