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() {
- {activeTab
- ? `Add YAML blocks to .slmcode/blocks/ or click "New ${KIND_TITLES[activeTab]}" above`
- : 'Add YAML blocks to .slmcode/blocks/ or ~/.slmcode/blocks/'}
+
+ 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/.