Skip to content

Commit 1fec851

Browse files
Efficiency, robustness and Studio: fewer model calls, retries instead of dead ends, a live and linked Studio (#38)
* feat(studio): blocked tasks tell their story and offer a next step A blocked card rendered only task.error, the last line of a longer story. The expanded card now shows the attempts timeline (attempt_log), the review verdict, the structured criteria checklist, and a next-step row: Send back to Ready, Retry (POST /api/tasks/{id}/retry, falling back to the Ready move on a server without the endpoint), Reassign team (opens the edit form on the team control) and Steer (prefills LiveFeedback with @task:ID, parking the request across navigation when the composer is not mounted). LiveTaskPanel's Blocked/Failed counters are now filters over the list, every attention row carries Retry, and a focusTaskId prop expands, scrolls to and flashes one task. Also: additive Task/QuerySession/PendingChange fields, a shared window-event contract (ui/events.ts), an ErrorState primitive, a roving tabindex helper, a confetti layer, and Modal's focus trap extracted into useFocusTrap so other dialogs can reuse it. * feat(studio): a run's end is not a dead end, and the keyboard reaches everything ResultPanel grows a What-next row: Resume interrupted run, Open blocked on Board (/board?column=blocked), Review N pending, Run again with this prompt. All data can be passed as props by the Live view; without props the panel fetches the resumable list and newest prompt itself and runs the prompt through RUN_PROMPT_EVENT, starting it directly when no listener claims the event. Layout reacts to run_end: a success or error toast, a flashing tab title while the tab is hidden, a browser Notification when permission was already granted and the tab is hidden, and a two-second confetti layer on a green run (skipped under prefers-reduced-motion). A run_end replayed from the server snapshot on page load is ignored. Keyboard: g t goes to Teams; Cmd/Ctrl+Enter and Cmd/Ctrl+. dispatch RUN_PROMPT_EVENT / STOP_RUN_EVENT while the run prompt is focused; Cmd/Ctrl+K opens a self-contained command palette over pages, tasks, agents, teams, recent runs and the theme. ShortcutSheet picks the new rows up from the shortcut table. * fix(studio): no more silent failures on the editor pages SettingsPanel and MarkdownEditor swallowed save errors — a 409 during a run left Save looking like a no-op. Both now report through the shared toast, naming the active run as the reason and saying the edits are kept. AgentManager, SkillManager and PipelineEditor render an inline ErrorState with Retry when their list fails to load instead of an empty state over a dead backend, and their secondary loads and reset report through the toast. FileInspector drops its private toast for the shared one and accepts /files?path= to open a file directly. ReviewView refreshes on the review_pending SSE event and on a 15 s poll while a run is writing, not only when it stops; each change shows its provenance (task, agent, run) with links, plus Open in Files. Empty states on Review, Agents and Skills say what the page is for and offer the first action. * loop: one reviewer per review, fast-reject on gates, resume timed-out workers The review path was a speculative race: at the default max_parallel it fanned out to a local "acceptance" probe, the reviewer and reviewer-strict at once. The probe re-tested a subset of fastPath's conditions with none of its guards (open criteria, scope, blocking gates, tester role), so a task fastPath had just declined could be approved "acceptance race won" with no reviewer; and reviewer-strict was dispatched on every review while its answer was used only when the primary's was empty. The race is gone: decideReview is fastPath, then a harness fast-reject (a fired hard gate, or a worker that finalized blocked with no evidence — the reviewer could not change either outcome), then ONE reviewer request; reviewer-strict runs sequentially and only when the primary produced no readable verdict. Every dispatch goes through execOne, so the runway clamp and the call budget apply to reviews as they do everywhere else; the speculate primitive keeps its tests but no longer sits on the review path. A reviewer transport error no longer blocks the task and cascades to its dependents: the gathered gate signals are judged first, the reviewer is re-asked once when budget and runway allow, and otherwise the task goes to the human backlog as "reviewer unavailable". Worker failures: a timed-out worker is re-queued to resume from its ReAct checkpoint while under the attempt ceiling and parked in to_scope only at the ceiling; transient and rate-limited errors get one bounded retry that honors Retry-After; the checkpoint is saved before any retry so the retry resumes instead of cold-starting. The timeout message reports the clamped per-call budget the request was actually dispatched with. * prompts: corrector keeps the harness evidence; worker prompt puts the pack first 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 Task.Output, so once the prose passed the budget the corrector was told to fix a smoke failure it was never shown. It now uses the same body / evidence split the reviewer prompt uses (clipForReview): the prose is budgeted and the evidence kept whole. It also carries the HARD SCOPE focus list (via the new shared agents.FocusFilesSection, which BuildWorkerPrompt uses too), the acceptance and the project language hint, so the corrector is shown the same scope the gate grades it against. The self-critique dispatch is clamped to the runway like every other call. BuildWorkerPrompt opened with "ID / Title / Column / Role" BEFORE the scoped context pack, so two tasks sharing one pack shared a prefix of a few dozen bytes and the whole pack was re-prefilled per task. When the description starts with a pack, the pack (and the project-wide language line) now come first and the task header sits immediately before "## Task instructions"; the Column line, which changed between attempts at the same task, is dropped. Descriptions without a pack keep their shape. * backends: an explicitly set temperature of 0 is emitted, not treated as unset reviewer-strict pins Temperature 0.0, and buildBody emitted the key only when the value was > 0 — so the one temperature that was set on purpose was dropped and the server sampled at its default. RoleSpec gains TemperatureSet, set for reviewer-strict and carried on the provider Directives; the structured request always emits `temperature` for a role that set one, while roles that never set it keep the unset behavior. The delegated (non-structured) path is built inside GoLangGraph and still applies its default to a zero; docs/decoding.md says so. * feat(studio): run history that reads at a glance, dialogs that trap focus, tabs that roam RunHistory list rows show the archive's numbers when the server sends them — duration, tasks done/total, failed count, tokens, cost and team chips — and stay as they were for older archives. Every run gets a Replay on the floor button (navigates to /?run=ID&replay=1; harmless while Live ignores the parameters), /runs?run=ID preselects a run, and the empty state distinguishes no runs from no filter match with a CTA. HITLPopup reuses Modal's focus trap: Tab stays inside the gate and Esc returns focus to the first action with a note on why the gate stays, since a gate cannot be dismissed. The Artifacts tabs on Runs and the kind tabs on Blocks use a roving tabindex with aria-controls. Blocks' empty state explains what a block is and offers the first one. TopBar's model search waits 200 ms and drops superseded answers via an AbortController instead of firing per keystroke. docs/studio.md gains the stuck-task story, the run-end actions, the keyboard table and the cross-component event/URL contract. * backends: elide old tool results on live ReAct requests Live ReAct compaction, deterministic half only. A ReAct iteration is one provider completion and every role is bound to its own registration, so a provider wrapper is the one place that sees iteration N of a 16-iteration worker with its full transcript — the same route live token streaming took. BindRole now stacks liveElideProvider outermost (above the structured wrapper, so the delegate and the direct constrained-decoding call send the same transcript) for any role whose Directives carry a LiveElide policy. Once the estimated transcript passes AtPercent of the model's context window the content of every tool RESULT but the last five is replaced with a placeholder. Every tool call, every tool_call_id pair, the leading system message and every user/assistant turn stay; nothing is summarized — on a live request the head is the role's tool contract. The rewrite is per request and never mutates the agent's conversation, so it is repeatable and the elided prefix is byte-identical between iterations until the keep window slides. The factory sizes the policy per tool-using role from the model profile's context_limit; the orchestrator sets it from react_compact and react_compact_at_percent. Tool-less roles, an unset policy and a model with no known window install nothing. The config docstring, schema description, readiness comment and docs/context.md describe both paths. * loop: resume steer reaches the model behind pending tool calls; live compaction is wired A checkpoint is saved when tool calls are still pending, so the restored transcript ends on an assistant tool_calls message and the finalize steer could not be appended as a user turn there. It was left to req.Input — and the agent library adds Input as a user turn on a cold start only, keeping it out of a seeded conversation. So the one resume that most needed the turn-budget warning never saw it. The steer is now injected as a system message right after the leading system message on that path, the technique CompactChatMessagesWithDigest uses for its digest. LiveReactCompactionWired flips to true: pkg/backends now elides old tool results on live requests for tool-using roles (deterministic elision only; summarization stays at checkpoint and resume). ReactCompactionStatus says so, and the readiness and config consumers read it as before. * fix(workspace,hitl,review): close the dangling-symlink jail gap, apply review entries by kind, per-ask shell approvals Workspace: - checkSymlinkEscape returned nil when EvalSymlinks failed, so a symlink whose target did not exist yet passed the jail and os.WriteFile followed it, creating the target anywhere on the host. Dangling links are now resolved with Readlink and judged lexically against the real root (re-checked for a chained link); anything that cannot be judged is refused. - ws_edit/ws_write/ws_patch accept the argument spellings small models mix in (old_string/search/old, new_string/replace/new, contents/text/body, diff/hunk); the canonical key always wins. - ws_write refuses an empty body on a new file (it used to write 0 bytes and report success) and names the accepted keys; emptying an existing file needs allow_shrink. - A ws_read gutter on EVERY line of old_str/patch is stripped and the edit proceeds, with a note appended so evolve still sees the drift; a partial gutter is still refused by name. - replace_all is honored on the tolerant ladder too: all spans of the first matching strategy are replaced instead of reporting ambiguity. - ws_patch: pure-insertion hunks (-N,0) insert AFTER line N as unified diff semantics require; the marker-less SEARCH/REPLACE form is reachable; trailing prose and Markdown fences no longer become unmatched context lines. - The syntax guard memoizes the before-verdict per path by content hash, so consecutive edits spawn one checker each after the first instead of two. Review queue: - Entries are stamped with from (ws_mv source), task_id, agent and query_id at record time; a shell ask is no longer mirrored into the queue. - Both appliers (Studio POST /api/review/apply and slmcode apply) switch on kind: delete removes, mv moves the source, shell is hidden and refused. Applying every kind as a write truncated deleted files, left moved sources behind and wrote shell.sh at the project root. - review_pending {pending: N} is emitted after apply/reject and whenever a running agent records a proposal. HITL: - Shell asks live in per-ask files (shell/asks/<id>.json, answers/<id>.json). The single ask.json slot let a second parallel worker overwrite the first ask and delete the winner's answer. GET /api/shell/pending returns asks[] alongside the first ask's fields for compatibility. - plan.AddBoardHook / workspace.AddPendingHook are the change feeds Studio turns into task_update / review_pending events. * loop, agents: comments no longer describe the removed review race * feat(studio): one vocabulary and one chip for every identifier Column labels, ticket-state labels and seat/role glyphs move to components/shared/labels.ts; the flat and 3D floors and the dossier read them from there instead of each carrying a copy. API field names are untouched — only what a person reads is unified (Team, Manager, Task; the floor keeps "ticket" for what lies on a table). EntityLink renders a task, agent, team, run or file as one consistent chip that goes somewhere: task/agent -> Live ?task=/?agent= (the dossier), team -> Teams ?team=, run -> Runs ?run=, file -> Files ?file=. Outside a router it degrades to a plain anchor to the same path. Types: QuerySession gains the per-run totals the server is adding (duration_ms, tokens, cost_usd, tasks_total, tasks_done, failed_tasks, teams); RunEvent.data may carry a task (task_update) or a pending count (review_pending). Client: retryTask(id) for POST /api/tasks/{id}/retry. * feat(studio): the floor keeps its state, renders on demand and can be walked by keyboard State: the selection is now controlled by the page (LiveView keeps it in the URL) with the wrapper's own state as the fallback; the pulse feed, the last floor it was diffed against, the camera, follow and spin live in a module-level store mirrored to sessionStorage, so leaving for the Board and coming back finds the scene where it was left and what happened in between still pulses. "reset view" restores OrbitControls' saved state instead of remounting the Canvas. Rendering: frameloop is 'always' only while something moves (a run, live tickets, fresh pulses, follow, spin), 'demand' when the floor is still — camera glides and hover eases ask for their own frames — and 'never' while the tab is hidden. Per-frame allocations are gone: scratch vectors for the lerps and camera maths, precomputed emissive colours, curve.getPoint(u, target), and one wall-clock read per frame shared by everything that ages a pulse; per-table pulse filtering is memoised. Robustness: the body cursor is reset when the pointer leaves the canvas; webglcontextlost drops the page to the flat map with a toast, and picking 3D again retries. Mobile: below sm the 3D/map toggle sits in a strip under the floor and the feed opens from it as a bottom sheet; a coarse pointer with no stored preference defaults to the map. Keyboard: every person and ticket is also a visually hidden button that opens the same dossier; the dossier moves focus to its close button on open and restores it on close, and its Esc handler ignores typing targets. * feat(studio): one live board fed by the stream, a per-event run derivation, and URL-backed selection Board store (hooks/useBoardStore): seeded from GET /api/tasks and GET /api/squads, updated by the stream's task_update events (data.task replaced by id), re-read every 30 s as a safety net, on both edges of a run, on reconnect, and — until the server pushes its first task_update — shortly after a structural log line, so an older server still feels live. task_update and review_pending are board events: folded into the store and the review badge, never rows in the log. The Board, the Live floor, the ticker and the Teams page read it; their own pollers (3 s, 5 s, 5 s) are gone, as is the Sidebar's second health poll — the review count comes from the stream's health poll and the review_pending event. useBoardStore() is exported so the task rail can switch to it. Run derivation (hooks/runDerived): phases seen, active phase and agent, task ids, files written, token/cost totals and the newest composition are folded once per event in the stream hook and published per flush; LiveView, NowBar and ActivityRail consume it instead of scanning the whole log. EventLog caches describeEvent/level/signature by event identity, folds its insight summary incrementally, windows its rows to the viewport plus a margin while keeping stick-to-bottom, and its agent, task and file chips are EntityLinks. URL state: the floor's selection lives in ?task= / ?agent= (&team=), so a dossier survives navigation and reload and can be linked to; the Board keeps ?team= and ?column= (a new per-column filter). focusTicket now passes the id through as focusTaskId to the Tasks rail (LiveTaskPanel accepts the prop; acting on it is left to its owner). NowBar derives who is working from the floor model's working set (readActivity/nowFor, now exported), so the clock stops after agent_end, and its agent, task and team are links. * docs(studio): the board store, its events, entity links and what the floor remembers * feat(squads): a seam task is handed the whole contract An unassigned task whose files fall in two or more squads' territory used to get no brief at all — BriefFor returned "" for every task without a squad — so the one worker building both sides of the seam was the one building without the interface spec. BriefFor now resolves the task's files to their owners and, when two or more squads own them, returns a bounded seam brief: the squads spanned and what each owns, then every interface of the frozen contract with its provider, consumers and a clipped spec. Interfaces only, no charters; capped at twelve interfaces and 320 chars of spec each. An unassigned task inside one territory, or outside every territory, still gets nothing. * feat(loop): measured per-role budgets and latency samples for loop requests Runner gains two optional hooks: RoleTimeout(ctx, role) returns the measured budget for one request of a role, and OnRoleLatency(role, d, evidence) reports how long each request took. execOne and dispatchWave dispatch on min(callTimeout, RoleTimeout(base role)) — the measured value can only tighten the flat Timeout — and report every request's duration under its base role (an escalation rung shares its base role's series) with the orchestrator's evidence rule: a success or a timeout counts, any other failure does not. reviewSlots sizes the reviewer slot from the same budget. Workers, reviewers and correctors own most of a run's wall clock and used to run on the flat task_timeout while contributing no latency samples at all; with the hooks unset nothing changes. * fix(orchestrator): one run-wide concurrency gate, smoke memo, per-run team state, early team gate, fewer idle model calls One concurrency limit. max_parallel was enforced only in the execute wave; context ran beside explore, architect beside clarify, speculative digs and review races added slots. Every model request now takes a slot of one run-wide weighted semaphore sized max_parallel, acquired in the executor adapter handed to the loop, in runRoleTracked and around the multipass cycle; the phase pairs run sequentially at max_parallel=1. llm_requests counts every assistant turn of a result's transcript instead of one per result, and the multipass passes are counted too. Measured role timeouts in the loop. buildRunner wires Runner.RoleTimeout to roleTimeoutWithin and Runner.OnRoleLatency to recordRoleLatency, so loop roles are dispatched on their measured budget and finally feed latency memory. Smoke memo. runSmokeIn remembers each command's result per (command, tree fingerprint) for the run and reuses it until something is written; the fingerprint now carries a write sequence so a rewrite of an already-changed file counts, and the formatter and a dependency install advance it. The pre-test result is handed to runQAGate for round 1; team acceptance and integration check gateRoundAffordable before running. The QA bootstrap is applied once per tree state. Stale per-run state. Team gates, the squad plan, the single team and the team selection memo are reset at the top of both runSLM and Resume; the squad plan write is locked; restoreSquadPlan restores a one-squad squads.json as the single staffing team. Correction attempts for team tickets are now counted under the key the ticket is stamped with (they were looked up under a key nothing matched, so every ticket was attempt 1). Model calls with nothing to do. The memory distillation runs single-shot and only when the run left a notable lesson, a changed file or a failed task; the per-wave distillation and after-wave coordinator run only on a wave that failed, escalated or produced a notable lesson; the splitter is skipped for a one-step plan over one or two known files. Every skip is announced as an info event with its reason. Early team gate. AfterWave proves a team's half as soon as its lane is complete and has changed since it was last proved, so a red half's ticket rides the next wave; a defect is ticketed at most twice per run, then handed to a human. Team selection is memoized per run so the composer and the charter phase do not both rescan the workspace and reload the block library. * backends: make the slow-endpoint probe test independent of machine load The test slept 100ms on every request against a 150ms probe budget, so a busy test run could make the very first request miss the window and fail the guard for the wrong reason. The first request now answers at once and only the later ones sleep past the deadline, which is the shape the test describes. * fix(server,session): run lifecycle races, lock-free latest-run snapshot, buffered event log; Studio task/review/history API Server lifecycle: - POST /api/runs/stop no longer flips running to false while the run goroutine is still unwinding. It sets stopping, cancels the run's own context and lets finishRun clear running when the goroutine exits, so a following POST /api/runs is a 409 until then. finishRun only touches shared state when its run generation is still current, so a stale goroutine can never clear the live run's flag, restore its options or publish over its result. - hitl.ClearAll runs after the running check: a second click on Run (a 409) no longer deletes the in-flight run's open shell ask. - The SSE ring is not cleared per run (subscribers were mid-stream on it); /api/runs/latest and the current-run activity view scope by runStartSeq. - handleLatestRun copies under s.mu and encodes after release: the engine emits synchronously into Server.emit, so a stalled browser fetch used to block a worker mid tool-call. - /api/runs/latest and /api/status report stopping. Studio API: - task_update events: every board task change (plan.AddBoardHook) is published with phase = current run phase or "board", task_id, message "<id> -> <column>" and data {"task": <task as GET /api/tasks renders it>}. - POST /api/tasks/{id}/retry: ready_to_dev, retries reset, error cleared, "retried from Studio" appended to attempt_log, persisted, task_update emitted; 409 without a board, 404 for an unknown id. - GET /api/queries items carry duration_ms, tokens, cost_usd, tasks_total, tasks_done, failed_tasks and teams; token/cost totals are cached per (id, size, mtime) of the event log. - GET /api/teams/activity?query= caches the derived timeline per (query, size, mtime) instead of re-parsing the log on every visit. Session event log: - One buffered appender per run instead of open/write/close per event under a global mutex. Token deltas are not persisted; structural kinds flush on write; chatty kinds flush on a 500ms timer; run_end/run_stop and the turn's end (WriteTurnSummary) flush, fsync and close. ReadEvents flushes first so a live run's tail is always visible. * fix(backends,cli,workspace): retry streamed calls before the first delta, restore the gate terminal on cancel, keep evolve informed of in-tool repairs; docs - retryProvider.CompleteStream / CompleteStreamWithMode now go through retryDo. The ReAct loop takes the streaming 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 exactly when someone was watching. A callback wrapper tracks whether a delta was delivered: a failure before any delta is retried like a failed Complete (fresh deadline per attempt, the caller's callback only sees the successful attempt's deltas); once a delta reached the callback the error is surfaced as-is, since a partial stream cannot be replayed. - TerminalGateHost.AskGate restores the terminal on the ctx.Done branch. The read goroutine owned raw mode and stayed blocked in ReadKey, so a gate abandoned by a stop or shutdown left the shell without echo. The goroutine's own release skips the restore when a newer gate has since taken the terminal. - A ws_edit/ws_patch call whose ws_read gutter was stripped in-tool is still presented to the tool observer as the refusal it would have been, followed by a repaired retry that landed, without a second edit — so the failure is fingerprinted, the memory rule credited and the metrics row moves. - docs: tools.md, troubleshooting.md (aliases, empty-content refusal, gutter stripping, ws_patch insertion/bare-form/prose semantics), studio.md (review kinds and stamps, task_update / review_pending events, retry endpoint, queries fields, shell pending list, stopping), permissions.md (kind-aware apply, per-ask shell files). * docs: changelog for the efficiency, robustness and Studio round; board store also refreshes on agent_end The engine emits no task_start/task_done kinds of its own, so on a server without task_update events the store's structural cue is the end of the role that worked the task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL * review applier refuses paths outside the project; annotate sanitized-id stats for the newer gosec CI's golangci-lint 2.13.1 carries gosec's taint analysis (G703). The review applier now checks every pending path against the project root before touching it, which is a real guarantee rather than a comment; the two os.Stat calls in the team-activity cache are annotated, since session.TurnDir already reduces the id to [A-Za-z0-9_-]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aef8eba commit 1fec851

152 files changed

Lines changed: 13970 additions & 1593 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/slmcode/cmd_review.go‎

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,50 @@ type pendingPatch struct {
3030
Path string `json:"path"`
3131
Kind string `json:"kind"`
3232
Content string `json:"content"`
33+
// From is the source of a ws_mv proposal (stamped by pkg/workspace).
34+
From string `json:"from,omitempty"`
35+
// TaskID / Agent / QueryID attribute the proposal when known.
36+
TaskID string `json:"task_id,omitempty"`
37+
Agent string `json:"agent,omitempty"`
38+
QueryID string `json:"query_id,omitempty"`
3339
}
3440

41+
// Queue kinds that are not "write Content to Path". See writePatch.
42+
const (
43+
pendingKindDelete = "delete"
44+
pendingKindMove = "mv"
45+
pendingKindShell = "shell"
46+
)
47+
3548
// abs resolves the target file inside the project root.
3649
func (p pendingPatch) abs(root string) string { return filepath.Join(root, p.Path) }
3750

38-
// before reads the on-disk content the patch would replace.
51+
// before reads the on-disk content the patch would replace. A move is diffed
52+
// against its SOURCE: the destination does not exist yet, and what the
53+
// reviewer needs to see is that the content is unchanged in transit.
3954
func (p pendingPatch) before(root string) string {
40-
data, err := os.ReadFile(p.abs(root))
55+
path := p.abs(root)
56+
if p.Kind == pendingKindMove && p.From != "" {
57+
path = filepath.Join(root, p.From)
58+
}
59+
data, err := os.ReadFile(path) //nolint:gosec // project path under root
4160
if err != nil {
4261
return ""
4362
}
4463
return string(data)
4564
}
4665

66+
// after is the content the applier will leave at Path ("" for a delete).
67+
func (p pendingPatch) after() string {
68+
if p.Kind == pendingKindDelete {
69+
return ""
70+
}
71+
return p.Content
72+
}
73+
4774
// diff computes the unified diff for this patch.
4875
func (p pendingPatch) diff(root string) cli.FileDiff {
49-
fd := cli.Diff(p.Path, p.before(root), p.Content, 3)
76+
fd := cli.Diff(p.Path, p.before(root), p.after(), 3)
5077
if mode, ok := fileMode(p.abs(root)); ok && mode&0o111 != 0 {
5178
fd.ModeNote = fmt.Sprintf("mode %04o", mode)
5279
}
@@ -87,17 +114,76 @@ func loadPending(slmDir string) ([]pendingPatch, error) {
87114
if json.Unmarshal(data, &p) != nil || strings.TrimSpace(p.Path) == "" {
88115
continue
89116
}
117+
if p.Kind == pendingKindShell {
118+
// An older workspace mirrored every shell ask into the queue;
119+
// a command is not a file change and must never be "applied".
120+
continue
121+
}
90122
p.File = e.Name()
91123
out = append(out, p)
92124
}
93125
sort.Slice(out, func(i, j int) bool { return out[i].File < out[j].File })
94126
return out, nil
95127
}
96128

97-
// writePatch applies one patch, preserving the existing file mode. A brand new
98-
// file gets 0o644; an existing executable keeps its +x bits.
129+
// writePatch applies one patch according to its KIND, preserving the existing
130+
// file mode for writes. A brand new file gets 0o644; an existing executable
131+
// keeps its +x bits.
132+
//
133+
// Every kind used to be applied as "write Content to Path", which truncated a
134+
// "deleted" file to zero bytes, copied a moved file while leaving its source
135+
// behind, and wrote a literal shell.sh for a shell approval.
99136
func writePatch(root string, p pendingPatch) error {
100137
abs := p.abs(root)
138+
if !underRoot(root, abs) {
139+
return fmt.Errorf("refusing %s: the path resolves outside the project", p.Path)
140+
}
141+
if p.Kind == pendingKindMove && p.From != "" && !underRoot(root, filepath.Join(root, p.From)) {
142+
return fmt.Errorf("refusing %s: the source resolves outside the project", p.From)
143+
}
144+
switch p.Kind {
145+
case pendingKindShell:
146+
return errors.New("shell approvals are not file changes; reject the entry instead")
147+
case pendingKindDelete:
148+
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
149+
return err
150+
}
151+
return nil
152+
case pendingKindMove:
153+
if strings.TrimSpace(p.From) == "" {
154+
return errors.New("move entry has no source path")
155+
}
156+
src := filepath.Join(root, p.From)
157+
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state
158+
return err
159+
}
160+
if _, err := os.Stat(abs); err == nil {
161+
return fmt.Errorf("destination %s already exists", p.Path)
162+
}
163+
if _, err := os.Lstat(src); err != nil {
164+
if !os.IsNotExist(err) {
165+
return err
166+
}
167+
// Source gone in the meantime: the intent is the content at the
168+
// destination, which was recorded.
169+
return os.WriteFile(abs, []byte(p.Content), 0o644) //nolint:gosec // project source file, conventional perms
170+
}
171+
if err := os.Rename(src, abs); err == nil {
172+
return nil
173+
}
174+
data, err := os.ReadFile(src) //nolint:gosec // project path under root
175+
if err != nil {
176+
return err
177+
}
178+
mode := os.FileMode(0o644)
179+
if m, ok := fileMode(src); ok {
180+
mode = m
181+
}
182+
if err := os.WriteFile(abs, data, mode); err != nil { //nolint:gosec // abs is checked against root above; a project source file
183+
return err
184+
}
185+
return os.Remove(src)
186+
}
101187
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { //nolint:gosec // directory in the user's source tree — conventional 0755, not harness state
102188
return err
103189
}
@@ -788,3 +874,15 @@ func matchesAnyPrefix(rel string, prefixes []string) bool {
788874
}
789875
return false
790876
}
877+
878+
// underRoot reports whether abs lies inside root (lexically: no ".." escape).
879+
// The pending entries are written by the tool layer, which already jails
880+
// paths; this is the applier's own guarantee that a hand-edited entry cannot
881+
// point it elsewhere.
882+
func underRoot(root, abs string) bool {
883+
rel, err := filepath.Rel(root, abs)
884+
if err != nil {
885+
return false
886+
}
887+
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
888+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func writeKindFixture(t *testing.T, slmDir, name string, obj map[string]string) {
12+
t.Helper()
13+
dir := filepath.Join(slmDir, "pending")
14+
if err := os.MkdirAll(dir, 0o755); err != nil {
15+
t.Fatal(err)
16+
}
17+
body, err := json.Marshal(obj)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil {
22+
t.Fatal(err)
23+
}
24+
}
25+
26+
// `slmcode apply --all` used to write every entry's content to its path:
27+
// a delete became a 0-byte file, a move left its source, a shell ask became
28+
// a literal shell.sh. Each kind now does what it says.
29+
func TestWritePatchHonorsKind(t *testing.T) {
30+
root := t.TempDir()
31+
slm := filepath.Join(root, ".slmcode")
32+
33+
// delete
34+
if err := os.WriteFile(filepath.Join(root, "gone.go"), []byte("package a\n"), 0o644); err != nil {
35+
t.Fatal(err)
36+
}
37+
if err := writePatch(root, pendingPatch{Path: "gone.go", Kind: "delete"}); err != nil {
38+
t.Fatal(err)
39+
}
40+
if _, err := os.Stat(filepath.Join(root, "gone.go")); !os.IsNotExist(err) {
41+
t.Fatalf("delete left the file (err=%v)", err)
42+
}
43+
// deleting an already-missing file is not an error
44+
if err := writePatch(root, pendingPatch{Path: "gone.go", Kind: "delete"}); err != nil {
45+
t.Fatalf("second delete: %v", err)
46+
}
47+
48+
// mv
49+
if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil {
50+
t.Fatal(err)
51+
}
52+
if err := os.WriteFile(filepath.Join(root, "old", "a.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { //nolint:gosec // executable fixture
53+
t.Fatal(err)
54+
}
55+
if err := writePatch(root, pendingPatch{Path: "new/a.sh", Kind: "mv", From: "old/a.sh", Content: "#!/bin/sh\n"}); err != nil {
56+
t.Fatal(err)
57+
}
58+
if _, err := os.Stat(filepath.Join(root, "old", "a.sh")); !os.IsNotExist(err) {
59+
t.Fatalf("mv left the source (err=%v)", err)
60+
}
61+
if got, err := os.ReadFile(filepath.Join(root, "new", "a.sh")); err != nil || string(got) != "#!/bin/sh\n" {
62+
t.Fatalf("mv destination: %q err=%v", got, err)
63+
}
64+
// mv whose source vanished falls back to the recorded content
65+
if err := writePatch(root, pendingPatch{Path: "new/b.go", Kind: "mv", From: "old/b.go", Content: "package b\n"}); err != nil {
66+
t.Fatal(err)
67+
}
68+
if got, _ := os.ReadFile(filepath.Join(root, "new", "b.go")); string(got) != "package b\n" {
69+
t.Fatalf("mv fallback: %q", got)
70+
}
71+
// mv onto an existing destination is refused
72+
if err := writePatch(root, pendingPatch{Path: "new/b.go", Kind: "mv", From: "new/a.sh", Content: "x"}); err == nil {
73+
t.Fatal("mv over an existing destination was allowed")
74+
}
75+
76+
// shell: never a file
77+
err := writePatch(root, pendingPatch{Path: "shell.sh", Kind: "shell", Content: "rm -rf /"})
78+
if err == nil || !strings.Contains(err.Error(), "shell") {
79+
t.Fatalf("shell entry applied: err=%v", err)
80+
}
81+
if _, serr := os.Stat(filepath.Join(root, "shell.sh")); serr == nil {
82+
t.Fatal("shell.sh written to the project root")
83+
}
84+
85+
// loadPending hides stale shell mirrors entirely, and surfaces from/stamps.
86+
writeKindFixture(t, slm, "1000_shell_shell.sh.patch.json", map[string]string{"path": "shell.sh", "kind": "shell", "content": "ls"})
87+
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"})
88+
got, err := loadPending(slm)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
if len(got) != 1 || got[0].Kind != "mv" || got[0].From != "o.go" || got[0].TaskID != "T1" || got[0].Agent != "worker" {
93+
t.Fatalf("loadPending = %+v", got)
94+
}
95+
96+
// The diff of a delete is everything removed; of a move, source vs content.
97+
if err := os.WriteFile(filepath.Join(root, "d.go"), []byte("a\nb\n"), 0o644); err != nil {
98+
t.Fatal(err)
99+
}
100+
fd := pendingPatch{Path: "d.go", Kind: "delete"}.diff(root)
101+
if fd.Removed != 2 || fd.Added != 0 {
102+
t.Fatalf("delete diff: +%d -%d", fd.Added, fd.Removed)
103+
}
104+
fd = pendingPatch{Path: "moved.go", Kind: "mv", From: "d.go", Content: "a\nb\n"}.diff(root)
105+
if fd.Removed != 0 || fd.Added != 0 {
106+
t.Fatalf("mv diff should be empty for unchanged content: +%d -%d", fd.Added, fd.Removed)
107+
}
108+
}

‎docs/agents.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ see <a href="providers.md">Providers</a>. Budget diplomacy is a feature.
3434
| `escalate` | — | action JSON | HITL timeout arbitrator (retry/re-scope/…) ⚖️ |
3535
| `memory` | — | bullets | Learn 💾 |
3636
| `composer` | — | pipeline JSON | Assemble a task-specific pipeline (dynamic_pipeline) 🎯 |
37-
| `reviewer-strict` | — | approve JSON | Second opinion in the speculative review race (`max_parallel >= 3`), temperature 0 🔍🔍 |
37+
| `reviewer-strict` | — | approve JSON | Sequential second opinion, asked only when `reviewer` returns no readable verdict; temperature 0 🔍🔍 |
3838
| `describer` | — | prose | Architect half of the describer→editor pair (`architect_editor`) 🗣️ |
3939
| `editor` | ✅ + `find_models` / `mcp_call` | status | Editor half: applies a described change, minimal reasoning, strict format ✍️ |
4040

‎docs/calibration.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,27 @@ That last one is the delicate one, so it has three guardrails:
170170
The seed can only ever tighten a budget for a model fast enough that a role
171171
provably does not need the whole ceiling.
172172

173+
Where the two numbers are enforced:
174+
175+
* **`max_parallel` is one run-wide limit.** Every model request the harness
176+
makes — the phase roles, the execute loop's workers, reviewers and
177+
correctors, self-critique, triage, the speculative review race, the planner's
178+
multipass cycle — takes a slot of a single gate sized `max_parallel`. It used
179+
to bound only the execute wave, while context ran beside explore and a review
180+
race added slots of its own, so a `max_parallel: 1` endpoint still saw two or
181+
three requests queue on each other and every measured role timeout was
182+
inflated by queueing the harness itself created. At `max_parallel: 1` the
183+
phase pairs also run sequentially instead of racing for the one slot.
184+
* **Role latency memory covers the execute loop too.** The measured budget
185+
(`p95 × 1.5`, floored per role class, capped at `task_timeout`) used to apply
186+
only to the phase roles; workers, reviewers and correctors ran on the flat
187+
`task_timeout` and recorded no samples, so `reviewer` had none after a
188+
hundred runs. Each loop request is now dispatched on
189+
`min(task_timeout clamped to the runway, measured budget)` and records a
190+
sample under its base role (an escalation rung shares its base role's
191+
series). A reviewer measured at 20s is given its 60s floor, not the whole
192+
ceiling.
193+
173194
## Commands
174195

175196
```

0 commit comments

Comments
 (0)