diff --git a/Makefile b/Makefile
index c2a683da96..32e34be07e 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
# anywhere else — so a stale copy can't shadow a fresh one.
BINARY := bin/codeaf
-.PHONY: all build build-check build-cross debug demo-home embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \
+.PHONY: all build build-check build-cross debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \
changelog changelog-new changelog-check changelog-preview
# What the shipped binary is allowed to weigh, in bytes, checked in beside the
@@ -355,6 +355,12 @@ demo-home: build
go build -o $(DEMO_BINARY) ./cmd/codeaf-demo-home
@$(DEMO_BINARY) $(if $(DEMO_HOME),--into "$(DEMO_HOME)") $(if $(KEEP),--keep) --launch "$(CURDIR)/$(BINARY)"
+# clean-run opens bin/codeaf on a fresh state root holding only your settings
+# and keys (scripts/clean-run.sh), so a new build is tried from the same clean
+# start every time: no conversations, projects or tasks from ~/.codeaf.
+clean-run: build
+ @scripts/clean-run.sh
+
vet:
go vet ./...
diff --git a/PERF.md b/PERF.md
index e6b9c9bfb2..eb38a0e67e 100644
--- a/PERF.md
+++ b/PERF.md
@@ -135,6 +135,34 @@ all four, each with its own furrow artifact staged:
The budget is 54,600,000, two percent above darwin/amd64, the same headroom
every figure in this section was given, now over a smaller binary.
+It was reset a fifth time on 2026-09-23, when senior-dev moved inside the binary
+(`internal/seniordev`, the built-in programs wave). Like furrow's, this one is a
+decision and not a drift: the owner's direction is that the programs codeaf hands
+a whole task to are built into every codeaf build and exist nowhere else, so the
+limit rises by what the engine weighs. Measured before (`5cf6a821e`) and after
+(`45e505550`) it landed, with the flags `make build` uses, on Go 1.27.0:
+
+| platform | before | after | what senior-dev cost |
+| --- | --- | --- | --- |
+| darwin/arm64, furrow staged | 54,018,770 | 56,493,874 | 2,475,104 |
+| darwin/amd64 | 58,504,000 | 61,279,360 | 2,775,360 |
+| linux/arm64 | 52,560,032 | 54,984,864 | 2,424,832 |
+| linux/amd64 | 57,421,984 | 60,133,536 | 2,711,552 |
+
+Only darwin/arm64 had its furrow artifact on disk, so the other three rows are
+weighed without theirs: each difference is exact, and each absolute figure is
+short by that platform's artifact, about three megabytes. The budget rises by
+the largest difference, to 57,400,000 — this change's bill and nothing else.
+
+AND THE TABLE SHOWS A BILL THAT WAS ALREADY OWED, which this reset does not
+fold in. Before senior-dev, darwin/amd64 and linux/amd64 already weighed more
+than 54,600,000 without their furrow artifacts, and linux/arm64 was within about
+two megabytes of it before its own was added: the growth since the fourth reset
+crossed the cap everywhere but the laptop the budget is usually checked on. The
+CI size job reports it and does not block (`ci-full.yml`'s `size`), for the
+reason that job gives — which architecture the budget is measured on has to be
+agreed first — and that agreement, not a larger number here, is the fix.
+
## Adaptive run shutdown grace
`Agent.Close` cancels adaptive runs and their name calls, then gives all accepted
diff --git a/README.md b/README.md
index dee128d01d..c555736d03 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,10 @@ hand work off, see what is moving across every project, and step in only where
your judgment is needed. A factory, on your own machine, and the more you hand it
the more it does.
+**#1 on DeepSWE** of ten coding harnesses on the same model, ahead of Claude Code,
+Codex, OpenCode, Kilo and DeepSeek's own harness, at the lowest cost per solved
+issue ([benchmarks](#benchmarks)).
+
Written in Go as one small binary, with nothing else to install or run. Apache
2.0. By [AgentField AI](https://agentfield.ai?utm_source=github-readme&utm_campaign=codeaf-readme&utm_id=codeaf-readme-byline).
@@ -51,6 +55,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | bash
codeaf
```
+
+Pin a version, or build from source
+
The script puts the release binary for your platform in `~/.codeaf/bin`. To
pin a version, give it a tag from the
[releases page](https://github.com/Agent-Field/codeaf/releases), where the
@@ -63,6 +70,8 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash
To build it yourself: `git clone`, `make build`, `bin/codeaf`
([guide](docs/GUIDE.md#install)).
+
+
On first start it connects OpenRouter in your browser, or takes a key. Codex signs in
a ChatGPT plan from `/connect` or `codeaf connect codex`; DeepSeek, GLM, Kimi, MiniMax
and Qwen take keys; Ollama needs none.
@@ -133,19 +142,21 @@ A run is a task like any other, on `home`, with a room and a stop.
- **Coming soon, native:** [PR-AF](https://github.com/Agent-Field/pr-af), the #1
open-source code reviewer on Martian Code-Review-Bench.
-- **Coming soon, in the benchmark below:** the developer subharness against
- general harnesses on the same open model.
+- **Native now:** `/senior-dev`, the developer subharness. First of ten
+ harnesses on DeepSWE, in the [benchmark below](#benchmarks).
- **Your own:** "make me a harness for triaging flaky tests" designs one, saves
it, and `/subharness` runs it.
## Benchmarks
-Coming soon. The run is held-out GitHub issues, several seeds each, through
-CodeAF's developer subharness and the general harnesses on the same open model:
-pass rate, cost per issue and time per issue, with every failure, timeout and
-unpriced call written up in [BENCHMARKS.md](BENCHMARKS.md). The chart and the
-table land here when the run completes, and `bench/` runs it on your own
-repository.
+
+
+`/senior-dev`, CodeAF's developer subharness, against nine other coding harnesses
+on the same model, DeepSeek V4 Flash: 113 real GitHub issues from DeepSWE, graded
+by the official verifiers. It solved the most issues and paid the least for each
+one it solved.
+
+Every number, the method and the limits: [docs/benchmarks/deepswe](docs/benchmarks/deepswe/).
## The right model for each call
diff --git a/SIZE-BUDGET b/SIZE-BUDGET
index b480208c35..c1453c4181 100644
--- a/SIZE-BUDGET
+++ b/SIZE-BUDGET
@@ -1 +1 @@
-54600000
+57400000
diff --git a/assets/readme/benchmark-deepswe.webp b/assets/readme/benchmark-deepswe.webp
new file mode 100644
index 0000000000..0c6404068e
Binary files /dev/null and b/assets/readme/benchmark-deepswe.webp differ
diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go
new file mode 100644
index 0000000000..ce5f18cefa
--- /dev/null
+++ b/cmd/codeaf/carried.go
@@ -0,0 +1,937 @@
+package main
+
+// `codeaf …` for a program this build carries (internal/delegate): the
+// verb every one of them answers, from a person's shell and from the chat's
+// own run alike.
+//
+// TWO CALLERS, ONE LINE. The chat's run starts `codeaf senior-dev run --json
+// --dir … -- ` as its child, with the model API's address and token in
+// the child's environment; a person types the same verb at a shell with
+// neither. The environment is how the two are told apart: a child of a host
+// runs the program's body here and writes its records on stdout; a shell run
+// becomes the host itself — it serves the model API and starts the same child.
+//
+// ── A SHELL RUN IS THE SAME TWO PROCESSES A CHAT'S RUN IS ───────────────────
+//
+// The host reaches models the way every headless verb does — the person's own
+// profile, its services and its keys (config.Load) — and serves them to the
+// program through a model API of its own (internal/provider/modelapi), exactly
+// as the chat's run does: the program is started as a child of this very
+// executable with the API's address and token and no key, every call it makes
+// is metered, held to the ceiling the person set, written to this machine's
+// spending ledger, and kept as one turn of a conversation log in the run's own
+// record folder. What the chat draws on a task page, the host prints as lines.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/config"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/home"
+ lanes "github.com/Agent-Field/codeaf/internal/lane"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+ "github.com/Agent-Field/codeaf/internal/roles"
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/tui2/reltime"
+)
+
+// carriedStdout is where a carried verb writes what a person reads: its help,
+// and a shell run's lines or records. A variable so a test can read it back;
+// a child of a host writes its records to the real stdout whatever this says,
+// because that pipe is its host's.
+var carriedStdout io.Writer = os.Stdout
+
+// carriedStderr is where a shell run says what it is doing about its own
+// ending — the stop, and the wait for a last call's price — beside the lines
+// or records on stdout. A variable so a test can read it back.
+var carriedStderr io.Writer = os.Stderr
+
+// carriedGrace overrides the launch's SIGTERM grace for a shell run, for a
+// test that must not wait fifteen seconds; zero is delegate.DefaultGrace.
+var carriedGrace time.Duration
+
+// runCarried runs one line of a carried program's verb and leaves on the exit
+// ladder (envelope.go).
+func runCarried(program delegate.Delegate, args []string) error {
+ inv, err := delegate.Parse(program, args, carriedStdout)
+ if errors.Is(err, delegate.ErrHelp) {
+ return exitDone
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ return exitCannotRun
+ }
+ // SIGTERM IS THE HOST'S STOP (internal/delegate's launch): the body's
+ // context ends, and the program writes its terminal on the way out.
+ ctx, stop := carriedSignals()
+ defer stop()
+ if _, child := delegate.ModelAPIFromEnv(); child {
+ return carriedExit(delegate.RunChild(ctx, inv, os.Stdout))
+ }
+ return runCarriedHost(ctx, inv)
+}
+
+// carriedSignals is a shell run's context: it ends on the first ctrl-c,
+// SIGTERM or hangup, and that first signal hands ctrl-c and SIGTERM back to the
+// terminal.
+//
+// A SECOND CTRL-C LEAVES AT ONCE. After the first one the run still waits for
+// the program's grace, its last calls to finish and the price of a call the
+// stop cut short — up to about a minute and a half, said on stderr as it
+// happens. Holding the signals for all of that swallowed a second ctrl-c, and
+// a person who means "now" is owed a way out that does not wait for money to
+// be counted. What leaving costs is said in the manual: the folder is
+// finished before that wait ([runCarriedHost]), so it is only a price still
+// being waited for that is then not in the run's line.
+//
+// A HANGUP IS A STOP, AND ONLY THE FIRST ONE IS HEARD. A closed terminal or a
+// dropped ssh connection sent SIGHUP, which nothing caught: the host died on
+// the spot, its program worked on unstopped, and the folder was left on the
+// program's branch with nothing said. It now stops the program and finishes
+// the folder the way ctrl-c does, and a second hangup — a shell passing one on
+// to its jobs as it exits — is ignored rather than allowed to kill that
+// finishing halfway.
+func carriedSignals() (context.Context, context.CancelFunc) {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
+ context.AfterFunc(ctx, func() {
+ signal.Ignore(syscall.SIGHUP)
+ stop()
+ })
+ return ctx, stop
+}
+
+// carriedRoad is how one shell run reaches models: the funnel a call on a
+// model goes out through, whether this person's services can take a call on a
+// model, and the work seat a call nothing here can serve is answered on.
+type carriedRoad struct {
+ completerFor func(model string) modelapi.Completer
+ serves func(model string) bool
+ seat string
+ // signModel is the model the attribution line on the one commit codeaf
+ // writes when the run ends names (internal/session's ProgramFolder.Finish):
+ // the work seat when the person's `attribution.model` row is on, and ""
+ // for the line that names none. The commit is signed either way.
+ signModel string
+}
+
+// carriedModels resolves a shell run's road. It is the person's own profile,
+// read the way `codeaf exec` reads it; a variable so a test can hand a
+// scripted road instead of a profile and a key.
+var carriedModels = profileRoad
+
+// profileRoad is the road through the person's profile: config.Load's
+// services and keys — so a machine with no key at all is answered with the
+// one sentence every command gives it — the crew's work seat, and one adapter
+// per model, built the way every client outside internal/config is built
+// ([config.Config.ClientConfig]).
+func profileRoad() (carriedRoad, error) {
+ settings, err := config.Load()
+ if err != nil {
+ return carriedRoad{}, err
+ }
+ useAutoSeats(settings)
+ seats := config.ResolveSeats(settings.ProfileDir, "", "")
+ settings.Models = sharedCatalog(settings)
+ adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}}
+ sources := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL)
+ return carriedRoad{
+ completerFor: adapters.forModel,
+ serves: func(model string) bool { return session.ServesModel(sources, model) },
+ seat: seats.Work.Model,
+ signModel: signedSeat(settings.ProfileDir, seats.Work.Model),
+ }, nil
+}
+
+// signedSeat is the model a shell run's commit names: the work seat when the
+// person's `attribution.model` row is on, and "" when it is off.
+func signedSeat(profileDir, seat string) string {
+ if !config.AttributionModelAt(profileDir) {
+ return ""
+ }
+ return seat
+}
+
+// carriedAdapters is one adapter per model a shell run's program asks for,
+// built once and kept for the run.
+type carriedAdapters struct {
+ settings config.Config
+ mu sync.Mutex
+ built map[string]modelapi.Completer
+}
+
+// forModel is the adapter for one model. THE WIRE SLUG IS THE LAST OPTION: a
+// program names a model in the person's own spelling — `openrouter/…`, a
+// connection's own prefix — and that spelling decides the account; what goes
+// on the wire is the service's own id for it, which config resolved beside
+// the account, exactly as the conversation's own door appends it
+// (internal/session's completeWithNamedModel).
+func (a *carriedAdapters) forModel(model string) modelapi.Completer {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if built, ok := a.built[model]; ok {
+ return built
+ }
+ configured := a.settings.ClientConfig(model)
+ client, err := provider.NewClient(configured)
+ if err != nil {
+ return refusingCompleter{err: err}
+ }
+ built := wireCompleter{client: client, wire: configured.Model}
+ a.built[model] = built
+ return built
+}
+
+// wireCompleter is one adapter with the model's wire slug appended to every
+// call.
+type wireCompleter struct {
+ client *provider.Client
+ wire string
+}
+
+func (c wireCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ return c.client.CompleteWithMessages(ctx, messages, append(options, ai.WithModel(c.wire))...)
+}
+
+// refusingCompleter is a model whose adapter could not be built: every call is
+// answered with why.
+type refusingCompleter struct{ err error }
+
+func (c refusingCompleter) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) {
+ return nil, c.err
+}
+
+// runCarriedHost is a person's shell run: this process serves the model API
+// with the person's own services and starts the program as its own child.
+func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error {
+ // A RUN WITH NOTHING TO DO IS NOT STARTED. The default command is a whole
+ // task, and a task with no brief is a program sent off to guess; the line
+ // that says what it wanted is the answer, and nothing is spent on the way.
+ if inv.Command.Name == inv.Program.Default && inv.Brief() == "" {
+ fmt.Fprintf(os.Stderr, "no brief given\n\n%s\n\nrun `codeaf %s --help` for its commands and flags.\n",
+ strings.Join(foldSynopsis("codeaf "+inv.Program.Name+" "+carriedSynopsis), "\n"), inv.Program.Name)
+ return exitCannotRun
+ }
+ road, err := carriedModels()
+ if err != nil {
+ return err
+ }
+ // A PERSON TYPED THIS AND IS WATCHING ITS LINES, which is the fact the
+ // lane layer reads for the calls that ride no context of the door's own
+ // (exec.go's typedDoorContext says the whole of why).
+ provider.SetPersonAtTheDoor(true)
+ record := carriedRecordDir(inv.Program.Name)
+ view := newCarriedView(carriedStdout, inv, record)
+ // THE FOLDER IS READIED BEFORE ANYTHING STARTS, the one way a conversation's
+ // run readies it (internal/session's programfolder.go): the folder itself,
+ // on a branch of its own in a repository, and a refusal — changes that are
+ // not committed, another program's run in it — before a cent is spent. A
+ // plain folder is no longer the program's first-line failure: codeaf says
+ // so on the program's line.
+ folder, err := carriedFolder(inv, record, road.signModel)
+ if err != nil {
+ fmt.Fprintln(carriedStderr, "error:", err)
+ return exitCannotRun
+ }
+ view.inFolder(folder)
+ // AND THE FOLDER IS FINISHED ON EVERY ROAD OUT: the run's ending below, or
+ // a door that failed before the program ever ran, which leaves nothing.
+ finished := false
+ finish := func(result string) {
+ if folder != nil && !finished {
+ finished = true
+ view.left(folder.Finish(result).Sentence())
+ }
+ }
+ defer finish("")
+
+ runCtx, cut := context.WithCancel(ctx)
+ defer cut()
+ // A LIMIT THE PERSON SET ENDS THE PROGRAM FROM OUTSIDE, whatever it does
+ // with the same figure on its own command line: the hours by a clock here,
+ // the dollars the moment a metered call reaches them. The API refuses the
+ // next call as well, so a program that ignores its SIGTERM cannot spend on.
+ var limited atomic.Bool
+ if wall := inv.Ceilings.Elapsed(); wall > 0 {
+ clock := time.AfterFunc(wall, func() {
+ limited.Store(true)
+ cut()
+ })
+ defer clock.Stop()
+ }
+ ledger := session.UsageLedgerPath()
+ // A SHELL RUN'S ROWS NAME THE RUN. There is no conversation and no task
+ // behind them, and a row that named nothing was money the spending page
+ // could not say anything about; the run's own record folder is the one
+ // name it has, so its rows are filed as one piece of work under it.
+ subject := filepath.Base(record)
+ api, err := modelapi.Open(modelapi.Config{
+ TaskDir: record,
+ CompleterFor: road.completerFor,
+ Serves: road.serves,
+ Seat: road.seat,
+ Ceiling: inv.Ceilings.CostUSD,
+ Bank: func(charge modelapi.Charge) {
+ // THE MACHINE'S SPENDING LEDGER, one row per call, written here and
+ // nowhere else: nothing else in this process meters these calls.
+ line := session.UsageLine{
+ Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD,
+ Reconciled: charge.Late, Workspace: inv.Workspace, Task: subject,
+ }
+ session.RecordUsage(ledger, session.TagUsage(line, roles.RoleWorker, session.SeatWorker))
+ view.call(charge)
+ if ceiling := inv.Ceilings.CostUSD; ceiling > 0 && charge.Spent >= ceiling {
+ limited.Store(true)
+ cut()
+ }
+ },
+ Unbilled: func(model string) {
+ session.RecordUnbilledCall(ledger, session.TagUsage(session.UsageLine{Model: model, Workspace: inv.Workspace, Task: subject}, roles.RoleWorker, session.SeatWorker))
+ },
+ // THE LAST CALL'S PRICE IS WAITED FOR, AND THE PERSON IS TOLD WHY. A
+ // run stopped by ctrl-c or its own ceiling is usually in the middle of
+ // a call, priced by a receipt about twenty seconds later; this process
+ // used to exit first, and that call never reached the ledger.
+ Settling: func(owed int) { fmt.Fprintln(carriedStderr, carriedSettlingLine(owed)) },
+ Role: lanes.RoleLeafAttached,
+ Node: inv.Program.Name,
+ })
+ if err != nil {
+ return err
+ }
+ defer func() { _ = api.Close() }()
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("find codeaf's own executable to run %s: %w", inv.Program.Name, err)
+ }
+ here, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("read the folder %s was started in: %w", inv.Program.Name, err)
+ }
+ grace := carriedGrace
+ if grace <= 0 {
+ grace = delegate.DefaultGrace
+ }
+ // CTRL-C IS A STOP, SAID AS ONE. The program is sent SIGTERM and given the
+ // grace to write how it ended; the person is told that much at once rather
+ // than left watching a terminal that has gone quiet for fifteen seconds.
+ untell := context.AfterFunc(ctx, func() {
+ fmt.Fprintf(carriedStderr, "stopping %s: it has %s to say how it ended\n", inv.Program.Name, grace)
+ })
+ view.begin()
+ // THE PROGRAM'S OWN CLOCK is written in its record folder, as a chat's
+ // run writes it in the task's: the instant its process was started and
+ // the instant it was gone (delegate.ProgramRecord).
+ started := time.Now()
+ view.opened(started)
+ result, runErr := delegate.Run(runCtx, delegate.Launch{
+ Name: inv.Program.Name,
+ Bin: exe,
+ Args: carriedInFolder(carriedChildLine(inv), inv, folder),
+ // NO KEY REACHES THE PROGRAM (delegate.ChildEnv): the API's address and
+ // token are the whole of what it is given.
+ Env: delegate.ChildEnv(api.API()),
+ Dir: here,
+ StderrPath: filepath.Join(record, carriedStderrName),
+ Grace: grace,
+ }, view)
+ // THE INSTANT THE PROCESS WAS GONE, not the instant its stdout drained, for
+ // both the last line and the record's end: a helper the program left holding
+ // stdout kept the launch open for up to the grace after the exit, and the
+ // same program read that much longer here than in a conversation, whose
+ // worker reads it this way ([delegate.Result.ExitedAt]).
+ ended := result.ExitedAt(started, time.Now())
+ untell()
+ // THE FOLDER IS FINISHED THE MOMENT THE PROGRAM HAS GONE, before its last
+ // prices are waited for. That wait is up to seventy seconds, a second
+ // ctrl-c during it leaves at once, and a folder finished after it was a
+ // folder left on the program's branch with its leftovers uncommitted and
+ // nothing said, for the next run to find. Nothing in the finishing needs
+ // the API: the program's ending is read off its last record, and its
+ // process — its whole group, on a stop — is already gone.
+ view.closed(ended)
+ finish(view.endingWords())
+ // The program has exited: its API goes with it, so nothing it left behind
+ // can spend, and every row it cost is on disk before this process leaves —
+ // the close waits for the price of a call the stop cut in the middle.
+ _ = carriedAPIClose(api)
+ session.CloseUsage()
+ return view.end(result, runErr, limited.Load(), api.Spent(), ended.Sub(started))
+}
+
+// carriedAPIClose closes a shell run's model API, which waits for the price
+// of a call a stop cut short; a variable so a test can see what is already
+// done by the time that wait begins.
+var carriedAPIClose = (*modelapi.Server).Close
+
+// carriedFolder readies the folder a shell run's program works in
+// (internal/session's PrepareProgramFolder); nil for a program that edits no
+// files, which reads the folder where it is.
+func carriedFolder(inv *delegate.Invocation, record string, signModel string) (*session.ProgramFolder, error) {
+ if !inv.Program.LandsTree() {
+ return nil, nil
+ }
+ return session.PrepareProgramFolder(session.ProgramFolderOrder{
+ Program: inv.Program, Dir: inv.Workspace, Brief: inv.Brief(),
+ Holder: "a run started at a shell", Keep: record, SignModel: signModel,
+ Instead: "run it in the project's folder, or name that folder with --dir",
+ })
+}
+
+// carriedInFolder puts on a shell run's child line what codeaf decided about
+// its folder, after --json and before the person's own words: the folder
+// itself when it is not the one the line names (a folder inside a repository
+// is worked in at the repository's root, and the person's own --dir is taken
+// off so it cannot win), and the program's own flags for a folder worked in
+// without git ([delegate.Delegate.PlainFolder]).
+func carriedInFolder(child []string, inv *delegate.Invocation, folder *session.ProgramFolder) []string {
+ if folder == nil {
+ return child
+ }
+ at := slices.Index(child, "--json")
+ if at < 0 {
+ return child
+ }
+ head, rest := append([]string(nil), child[:at+1]...), child[at+1:]
+ if folder.Dir != inv.Workspace {
+ flags, words := rest[:len(rest)-len(inv.Args)], rest[len(rest)-len(inv.Args):]
+ kept := make([]string, 0, len(flags))
+ for i := 0; i < len(flags); i++ {
+ switch flag := flags[i]; {
+ case flag == "--dir" || flag == "-dir":
+ i++
+ case strings.HasPrefix(flag, "--dir=") || strings.HasPrefix(flag, "-dir="):
+ default:
+ kept = append(kept, flag)
+ }
+ }
+ head = append(head, "--dir", folder.Dir)
+ rest = append(kept, words...)
+ }
+ if folder.Plain() {
+ head = append(head, inv.Program.PlainFolder...)
+ }
+ return append(head, rest...)
+}
+
+// carriedSettlingLine is what a shell run says while it waits for the
+// receipts still owed on the calls its ending cut short, bounded by the
+// provider's own schedule (provider.ReceiptWait).
+func carriedSettlingLine(owed int) string {
+ calls := "1 call that was"
+ if owed != 1 {
+ calls = strconv.Itoa(owed) + " calls that were"
+ }
+ return "waiting up to " + reltime.Elapsed(provider.ReceiptWait) + " for the price of " + calls + " cut short"
+}
+
+// carriedStderrName is the file a shell run keeps its program's stderr in,
+// beside the conversation log — the name the chat's run keeps it under too.
+const carriedStderrName = "delegate-stderr.log"
+
+// carriedRecordDir is a shell run's record folder: the conversation log, the
+// action log, the program record and the program's stderr, under this machine's state root
+// where a person can open them after the lines have scrolled away. It has no
+// task page to live beside, so it has a folder of its own, one per run.
+func carriedRecordDir(name string) string {
+ return filepath.Join(carriedRecordRoot(name), time.Now().Format("20060102-150405.000000"))
+}
+
+// carriedRecordRoot is the folder every shell run of one program keeps its
+// record under, one folder per run.
+func carriedRecordRoot(name string) string {
+ return home.Join("v3", "carried", name)
+}
+
+// carriedChildLine is the line a shell run starts its child with: THE
+// PERSON'S OWN LINE, with --json added after the command word. It is not
+// delegate.ChildArgs, which is the chat's line — the default command and the
+// shared flags only — because a person at a shell may name another command or
+// give the command a flag of its own, and a line rebuilt from the parsed
+// invocation would silently drop both. The child reads this line with the same
+// parser the host just read it with, from the same folder ([runCarriedHost]
+// starts it where it was started), so it arrives at the same invocation.
+func carriedChildLine(inv *delegate.Invocation) []string {
+ line := append([]string(nil), inv.Line...)
+ head := []string{inv.Program.Name}
+ if len(line) > 0 {
+ if _, named := inv.Program.Command(line[0]); named {
+ head, line = append(head, line[0]), line[1:]
+ }
+ }
+ head = append(head, "--json")
+ return append(head, line...)
+}
+
+// carriedExit is an ending on the exit ladder: the work stands, a limit you
+// set stopped it, or it ran and did not finish.
+func carriedExit(status string) error {
+ switch status {
+ case delegate.StatusPass:
+ return exitDone
+ case delegate.StatusBudget:
+ return exitLimit
+ default:
+ return exitIncomplete
+ }
+}
+
+// ── the front page ──────────────────────────────────────────────────────────
+
+// carriedHeading heads the group `codeaf --help` lists the carried programs
+// under, in the table's own register: what the group is, a dash, the one thing
+// a reader needs to know about all of it. It names no machinery: a person
+// reads the program's own name, never the word the code calls it by.
+const carriedHeading = "Hand it a whole task — a program codeaf carries does it on its own"
+
+// carriedSynopsis is the shape every carried program's line takes: the brief,
+// and the folder and the two ceilings codeaf puts on every one of them
+// (delegate.Parse). It is ONE LINE ON PURPOSE: the front page was cut to fit a
+// screen and a bit, and a program costs it two lines — this and its summary.
+// `--json`, the program's own commands and their flags are its `--help`.
+const carriedSynopsis = `"" [--dir dir] [--max-cost usd] [--max-hours h]`
+
+// carriedGroup is the group `codeaf --help` gives the programs a build
+// carries: one line per program in the table's shape, its summary under it.
+// A build that carries none gets no group at all — not a heading over nothing
+// — which is every Windows build.
+func carriedGroup(programs []delegate.Delegate) string {
+ if len(programs) == 0 {
+ return ""
+ }
+ lines := []string{carriedHeading}
+ for _, program := range programs {
+ lines = append(lines, foldSynopsis("codeaf "+program.Name+" "+carriedSynopsis)...)
+ indent := strings.Repeat(" ", helpTextColumn)
+ for _, line := range wrapAt(program.Summary, helpWidth-helpTextColumn) {
+ lines = append(lines, indent+line)
+ }
+ }
+ // A program's own commands and flags are its `--help`, which the page's
+ // last line already names for every command; saying it again here would be
+ // a line of the capped page spent on a sentence the reader has.
+ return strings.Join(lines, "\n")
+}
+
+// synopsisFold is the column a folded synopsis continues at: under the verb,
+// so the flags stay one column, which is where the table folds every other
+// command's (main.go's layout law).
+const synopsisFold = 14
+
+// foldSynopsis writes one command's synopsis at column 2 and folds it, when it
+// must, at [synopsisFold], so no line draws wider than [helpWidth].
+func foldSynopsis(synopsis string) []string {
+ words := strings.Fields(synopsis)
+ if len(words) == 0 {
+ return nil
+ }
+ lines := []string{" " + words[0]}
+ for _, word := range words[1:] {
+ last := len(lines) - 1
+ if len(lines[last])+1+len(word) > helpWidth {
+ lines = append(lines, strings.Repeat(" ", synopsisFold)+word)
+ continue
+ }
+ lines[last] += " " + word
+ }
+ return lines
+}
+
+// frontPage is `codeaf --help` as it is printed: the one table, with the
+// programs this build carries listed as a group of their own right after the
+// work you hand it — they are work you hand it, the whole of a task. The table
+// itself stays one constant ([usageText]) so every per-command page is still a
+// reading of it; the group is read from the build's list at the moment of
+// printing, because that list is what the build carries.
+func frontPage() string {
+ group := carriedGroup(builtin.All())
+ if group == "" {
+ return usageText
+ }
+ const after = "\nLook at what happened"
+ at := strings.Index(usageText, after)
+ if at < 0 {
+ return usageText + "\n\n" + group
+ }
+ return usageText[:at] + "\n" + group + "\n" + usageText[at:]
+}
+
+// ── what a person at the shell sees ─────────────────────────────────────────
+
+// carriedView is a shell run's delegate.Sink and its call line: the stage as
+// it changes, each step, each model call, and the ending, as lines a person
+// reads — or, with --json, the program's records passed through as records.
+// The reader's goroutine, the API's calls and the host itself all write here,
+// so every write is taken under one lock.
+type carriedView struct {
+ mu sync.Mutex
+ out io.Writer
+ inv *delegate.Invocation
+ record string
+ records *delegate.Emitter
+ stage string
+ status string
+ calls int
+ terminal *delegate.Terminal
+ // program is the run's program record as it stands, rewritten whole in
+ // the record folder each time it learns something: its start, its hello,
+ // its end.
+ program delegate.ProgramRecord
+ // folder is the folder the run was readied in, and leftFolder is how the
+ // run left it (internal/session's ProgramFolderEnd.Sentence); nil and
+ // empty for a program that edits no files.
+ folder *session.ProgramFolder
+ leftFolder string
+}
+
+func newCarriedView(out io.Writer, inv *delegate.Invocation, record string) *carriedView {
+ view := &carriedView{out: out, inv: inv, record: record}
+ if inv.JSON {
+ view.records = delegate.NewEmitter(out)
+ }
+ return view
+}
+
+// begin says what is starting, where.
+func (v *carriedView) begin() {
+ if v.records != nil {
+ return
+ }
+ v.say("%s · working in %s", v.inv.Program.Name, v.where())
+}
+
+// inFolder keeps the folder the run was readied in, for the line that says
+// where it works.
+func (v *carriedView) inFolder(folder *session.ProgramFolder) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.folder = folder
+}
+
+// where is the folder the program works in, as the first line says it: on its
+// own branch when codeaf cut one.
+func (v *carriedView) where() string {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ if v.folder == nil {
+ return v.inv.Workspace
+ }
+ if v.folder.Plain() {
+ return v.folder.Dir
+ }
+ return v.folder.Dir + ", on its own branch " + v.folder.Branch
+}
+
+// left keeps how the run left its folder, for the lines that end the run.
+func (v *carriedView) left(sentence string) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.leftFolder = sentence
+}
+
+// endingWords is the program's ending in the one sentence a person reads
+// ([carriedEnding]), the body of the commit that holds what it left.
+func (v *carriedView) endingWords() string {
+ terminal, _ := v.ending()
+ if terminal == nil {
+ return ""
+ }
+ return carriedEnding(v.inv.Program.Name, *terminal)
+}
+
+func (v *carriedView) Hello(h delegate.Hello) {
+ v.remember(func(record *delegate.ProgramRecord) { record.Stages = h.Stages })
+ if v.records != nil {
+ _ = v.records.Hello(v.inv.Program.Name, h.Stages)
+ }
+}
+
+// opened writes the program record the moment the program's process is
+// started: whose run it is, its ceiling, and when it began.
+func (v *carriedView) opened(at time.Time) {
+ v.remember(func(record *delegate.ProgramRecord) {
+ record.Name, record.CeilingUSD, record.StartedAt = v.inv.Program.Name, v.inv.Ceilings.CostUSD, at
+ })
+}
+
+// closed writes the instant the program's process was gone.
+func (v *carriedView) closed(at time.Time) {
+ v.remember(func(record *delegate.ProgramRecord) { record.EndedAt = at })
+}
+
+// remember changes the program record and writes it whole. It is a record, so
+// a disk that refuses it costs the record and never the run.
+func (v *carriedView) remember(change func(record *delegate.ProgramRecord)) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ change(&v.program)
+ if v.program.Name == "" {
+ v.program.Name = v.inv.Program.Name
+ }
+ _ = delegate.WriteProgram(v.record, v.program)
+}
+
+// kept writes one received record to the run's action log in its record
+// folder, stamped with the moment it arrived — the same log a chat's run keeps
+// beside its task (delegate.ActionsFile), so the two roads leave one record. It
+// is a record, so a disk that refuses it costs the record and never the run.
+func (v *carriedView) kept(action delegate.Action) {
+ if strings.TrimSpace(v.record) == "" {
+ return
+ }
+ _ = delegate.AppendAction(v.record, action)
+}
+
+func (v *carriedView) Stage(record delegate.StageRecord) {
+ v.kept(delegate.StageAction(time.Now(), record))
+ if v.records != nil {
+ _ = v.records.Stage(record)
+ return
+ }
+ stage, status := record.Stage, record.Status
+ if !v.moved(stage, status) {
+ return
+ }
+ if status == "" {
+ v.say("%s", stage)
+ return
+ }
+ v.say("%s · %s", stage, status)
+}
+
+// moved takes the program's new phase and answers whether it is a change: a
+// stage said twice is one line, not two.
+func (v *carriedView) moved(stage, status string) bool {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ changed := stage != v.stage || status != v.status
+ v.stage, v.status = stage, status
+ return changed
+}
+
+func (v *carriedView) Step(record delegate.StepRecord) {
+ v.kept(delegate.StepAction(time.Now(), record))
+ if v.records != nil {
+ _ = v.records.Step(record)
+ return
+ }
+ if head := firstLineOf(record.Observation); head != "" {
+ v.say(" %s · %s", record.Command, head)
+ return
+ }
+ v.say(" %s", record.Command)
+}
+
+func (v *carriedView) Terminal(t delegate.Terminal) {
+ v.keep(t)
+ v.kept(delegate.EndAction(time.Now(), t))
+ if v.records == nil {
+ return
+ }
+ // THE RECORD PASSES THROUGH AS THE PROGRAM WROTE IT: its data travels whole,
+ // every key the program put there, in the one terminal this stdout carries.
+ extra := make(map[string]any, len(t.Data))
+ for key, value := range t.Data {
+ extra[key] = value
+ }
+ _ = v.records.Terminal(delegate.Ending{Status: t.Status, Message: t.Message, Extra: extra})
+}
+
+// keep holds the program's ending for the run's last lines.
+func (v *carriedView) keep(t delegate.Terminal) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.terminal = &t
+}
+
+// counted counts one metered call.
+func (v *carriedView) counted() {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.calls++
+}
+
+// ending is the program's ending and how many calls it made.
+func (v *carriedView) ending() (*delegate.Terminal, int) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ return v.terminal, v.calls
+}
+
+// call is one metered model call: `model · N in · N out · $X`, with whatever
+// nobody measured left off rather than written as a zero.
+func (v *carriedView) call(charge modelapi.Charge) {
+ v.counted()
+ if v.records != nil {
+ return
+ }
+ parts := []string{charge.Model}
+ if charge.Model == "" {
+ parts[0] = "model"
+ }
+ if charge.TokensIn > 0 {
+ parts = append(parts, strconv.Itoa(charge.TokensIn)+" in")
+ }
+ if charge.TokensOut > 0 {
+ parts = append(parts, strconv.Itoa(charge.TokensOut)+" out")
+ }
+ if charge.CostUSD > 0 {
+ parts = append(parts, carriedDollars(charge.CostUSD))
+ }
+ v.say(" %s", strings.Join(parts, " · "))
+}
+
+// end says how the run ended and answers its rung on the exit ladder. took is
+// how long the program's process ran.
+func (v *carriedView) end(result delegate.Result, runErr error, limited bool, spent float64, took time.Duration) error {
+ terminal, calls := v.ending()
+ name := v.inv.Program.Name
+ if terminal == nil && result.ExitCode < 0 && !result.Stopped && runErr != nil && !errors.Is(runErr, delegate.ErrNoTerminal) {
+ // IT NEVER RAN: the process could not be started at all, which is the
+ // first rung of the ladder rather than work that did not finish.
+ fmt.Fprintln(os.Stderr, "error:", runErr)
+ return exitCannotRun
+ }
+ status := delegate.StatusCrashed
+ if terminal != nil {
+ status = terminal.Status
+ if !delegate.KnownStatus(status) {
+ status = delegate.StatusCrashed
+ }
+ }
+ if limited {
+ status = delegate.StatusBudget
+ }
+ if v.records != nil {
+ // THE RECORDS ARE THE PROGRAM'S, so where its folder was left goes to
+ // stderr beside them rather than into them.
+ if said := v.folderLine(); said != "" {
+ fmt.Fprintln(carriedStderr, said)
+ }
+ return carriedExit(status)
+ }
+ switch {
+ case limited:
+ line := name + " was stopped at a limit you set"
+ if terminal != nil && terminal.Message != "" {
+ line += ": " + terminal.Message
+ }
+ v.say("%s", line)
+ case terminal == nil:
+ line := fmt.Sprintf("%s exited %d without saying how it ended", name, result.ExitCode)
+ if result.Reading.LastStage != "" {
+ line += "; its last stage was " + result.Reading.LastStage
+ }
+ v.say("%s", line)
+ default:
+ v.say("%s", carriedEnding(name, *terminal))
+ if claim := terminal.Claim(); claim != "" {
+ v.say(" %s's model said: %s", name, claim)
+ }
+ if observed := terminal.Observed(); observed != "" {
+ v.say(" %s observed: %s", name, observed)
+ }
+ }
+ // WHERE THE WORK IS comes after how the run ended: its branch, checked out
+ // in the folder, and how to go back — the sentence a conversation's run
+ // says on its page.
+ if said := v.folderLine(); said != "" {
+ v.say(" %s", said)
+ }
+ // The folder the run's record is in comes before the last line, so that
+ // line is always what the run came to.
+ if _, err := os.Stat(v.record); err == nil {
+ v.say(" the run's record is in %s", v.record)
+ }
+ // THE LAST LINE IS WHAT THE RUN CAME TO: its calls, its dollars and how
+ // long the program ran, each left off rather than written as a zero. It is
+ // last because the manual says so and a person reading `tail -1` is told
+ // so; the record folder's line, which every real run has, used to follow it.
+ var summary []string
+ if calls > 0 {
+ word := "calls"
+ if calls == 1 {
+ word = "call"
+ }
+ summary = append(summary, fmt.Sprintf("%d model %s", calls, word))
+ if spent > 0 {
+ summary = append(summary, carriedDollars(spent))
+ }
+ }
+ if took >= time.Second {
+ summary = append(summary, reltime.Elapsed(took))
+ }
+ if len(summary) > 0 {
+ v.say(" %s", strings.Join(summary, " · "))
+ }
+ return carriedExit(status)
+}
+
+// folderLine is how the run left its folder, "" for a program that edits no
+// files.
+func (v *carriedView) folderLine() string {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ return v.leftFolder
+}
+
+// carriedEnding is the ending in one sentence, in the program's own words
+// after the one that says which of the four it was.
+func carriedEnding(name string, terminal delegate.Terminal) string {
+ message := strings.TrimSpace(terminal.Message)
+ var said string
+ switch terminal.Status {
+ case delegate.StatusPass:
+ said = name + " finished"
+ case delegate.StatusBudget:
+ said = name + " stopped at its ceiling"
+ case delegate.StatusFail:
+ said = name + " did not finish"
+ default:
+ said = name + " crashed"
+ }
+ if message == "" {
+ return said
+ }
+ return said + ": " + message
+}
+
+func (v *carriedView) say(format string, args ...any) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ fmt.Fprintf(v.out, format+"\n", args...)
+}
+
+// carriedDollars writes an amount in cents, and to four places under a cent
+// so one cheap call is not written as nothing.
+func carriedDollars(amount float64) string {
+ if amount < 0.01 {
+ return fmt.Sprintf("$%.4f", amount)
+ }
+ return fmt.Sprintf("$%.2f", amount)
+}
+
+// firstLineOf is the first line of a text, trimmed and cut to a row's width.
+func firstLineOf(text string) string {
+ line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
+ line = strings.TrimSpace(line)
+ if runes := []rune(line); len(runes) > 100 {
+ line = string(runes[:100]) + "…"
+ }
+ return line
+}
diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go
new file mode 100644
index 0000000000..990cd85f84
--- /dev/null
+++ b/cmd/codeaf/carried_child_test.go
@@ -0,0 +1,148 @@
+package main
+
+// The program this package's tests carry, and the door a shell run's REAL
+// child comes in by: carried_host_test.go starts this very test binary as the
+// program's process, exactly as a shell run starts codeaf's own executable —
+// the program's line after it, the model API's address and token in its
+// environment and no key — marked by [carriedChildEnv], and TestMain then runs
+// the whole dispatch (`execute`) with the fake program on the build's list.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// carriedChildEnv marks a process started as a shell run's child.
+const carriedChildEnv = "CODEAF_TEST_CARRIED_CHILD"
+
+// fakeCarried is the fake program's name. It is a word no verb of codeaf's
+// own is spelled with, which carried_test.go's collision law holds it to.
+const fakeCarried = "fake-carried"
+
+// fakeCarriedProgram is a program with a command flag of its own: `--calls`
+// questions to the model API, a step for each answer, and a passing ending —
+// or, with `--wait`, it waits to be stopped and says it was.
+func fakeCarriedProgram() delegate.Delegate {
+ return delegate.Delegate{
+ Name: fakeCarried, Summary: "a program the tests carry, which asks its model a question or two", Default: "run", Page: "delegates",
+ Guide: "For the tests' questions to a model, with a brief that is the question.",
+ Commands: []delegate.Command{{
+ Name: "run", Usage: "[flags] -- ", Summary: "does the whole task",
+ Bind: func(fs *flag.FlagSet) delegate.Body {
+ calls := fs.Int("calls", 1, "how many questions to ask the model")
+ wait := fs.Bool("wait", false, "wait to be stopped after the questions")
+ linger := fs.Duration("linger", 0, "leave a helper holding stdout this long after the program exits")
+ return func(ctx context.Context, host delegate.Host, args []string) error {
+ host.Hello([]string{"implement", "verify"})
+ host.Stage(delegate.StageRecord{Stage: "implement", Status: "running"})
+ for call := 1; call <= *calls && ctx.Err() == nil; call++ {
+ reply, err := askCarried(ctx, host.Models(), fmt.Sprintf("question %d: %s", call, strings.Join(args, " ")))
+ if err != nil {
+ host.Step(delegate.StepRecord{Command: "model: ask", Observation: "refused: " + err.Error()})
+ continue
+ }
+ host.Step(delegate.StepRecord{Command: "model: ask", Observation: reply})
+ }
+ if *wait || ctx.Err() != nil {
+ <-ctx.Done()
+ host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "stopped before it finished"})
+ return nil
+ }
+ host.Stage(delegate.StageRecord{Stage: "verify", Status: "pass"})
+ host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "submitted and verified", Claim: "the test is fixed", Observed: "pass"})
+ if *linger > 0 {
+ // A detached helper that inherited stdout and outlives the
+ // program, which keeps the launch draining after the exit.
+ helper := exec.Command("sleep", fmt.Sprintf("%g", linger.Seconds()))
+ helper.Stdout = os.Stdout
+ _ = helper.Start()
+ }
+ return nil
+ }
+ },
+ }, {
+ Name: "check", Usage: "", Summary: "says whether it could run",
+ Bind: func(*flag.FlagSet) delegate.Body {
+ return func(ctx context.Context, host delegate.Host, args []string) error {
+ host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "it could run"})
+ return nil
+ }
+ },
+ }},
+ }
+}
+
+// askCarried is one question through the model API, as any OpenAI client asks
+// one.
+func askCarried(ctx context.Context, api delegate.ModelAPI, question string) (string, error) {
+ body, _ := json.Marshal(map[string]any{
+ "model": "openrouter/deepseek/deepseek-v4-flash-0731",
+ "messages": []map[string]string{{"role": "user", "content": question}},
+ })
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, modelapi.ChatURL(api.BaseURL), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ api.Authorize(request)
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ return "", err
+ }
+ defer response.Body.Close()
+ payload, _ := io.ReadAll(response.Body)
+ var answer struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil {
+ return "", fmt.Errorf("%d: %s", response.StatusCode, payload)
+ }
+ if answer.Error != nil {
+ return "", errors.New(answer.Error.Message)
+ }
+ if len(answer.Choices) == 0 {
+ return "", errors.New("no answer")
+ }
+ return answer.Choices[0].Message.Content, nil
+}
+
+// runAsCarriedChild runs the dispatch when this binary was started as a shell
+// run's child, and says whether it was: with the fake program carried when
+// the mark is "1", the fake that works in its folder when it is "folder"
+// (carried_folder_test.go), and with the build's own list — senior-dev itself
+// — when it is "real".
+func runAsCarriedChild() (int, bool) {
+ switch os.Getenv(carriedChildEnv) {
+ case "1":
+ restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()})
+ defer restore()
+ return execute(), true
+ case "folder":
+ restore := builtin.Override([]delegate.Delegate{fakeFolderProgram()})
+ defer restore()
+ return execute(), true
+ case "real":
+ return execute(), true
+ }
+ return 0, false
+}
diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go
new file mode 100644
index 0000000000..caea76f56d
--- /dev/null
+++ b/cmd/codeaf/carried_folder_test.go
@@ -0,0 +1,277 @@
+//go:build !windows
+
+package main
+
+// A SHELL RUN WORKS IN ITS FOLDER THE WAY A CONVERSATION'S RUN DOES
+// (internal/session's programfolder.go): a plain folder is worked in as it is
+// with the program told so on its line — where it used to end at once with
+// "workspace is not a git repository" — a repository gets a branch of its own
+// that is left checked out with the work committed on it, and a checkout with
+// changes that are not committed is refused before anything is spent.
+
+import (
+ "bytes"
+ "context"
+ "flag"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// fakeFolder is the name of the fake that works in its folder.
+const fakeFolder = "fake-folder"
+
+// fakeFolderProgram is a program that edits files the way senior-dev does:
+// it says whether it was told it works without git, writes one file of work
+// and one of its own notes into the folder it was handed, and passes.
+func fakeFolderProgram() delegate.Delegate {
+ return delegate.Delegate{
+ Name: fakeFolder, Summary: "a program the tests carry, which writes a file where it is told to work", Default: "run", Page: "delegates",
+ Guide: "For the tests' folder work, with a brief that names the file.",
+ PlainFolder: []string{"--in-place"},
+ Notes: ".fake-folder",
+ Commands: []delegate.Command{{
+ Name: "run", Usage: "[flags] -- ", Summary: "does the whole task",
+ Bind: func(fs *flag.FlagSet) delegate.Body {
+ inPlace := fs.Bool("in-place", false, "work without git")
+ return func(ctx context.Context, host delegate.Host, args []string) error {
+ host.Hello([]string{"implement"})
+ mode := "git"
+ if *inPlace {
+ mode = "in place"
+ }
+ host.Step(delegate.StepRecord{Command: "folder", Observation: mode})
+ if err := os.WriteFile(filepath.Join(host.Workspace(), "made.txt"), []byte("made\n"), 0o644); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Join(host.Workspace(), ".fake-folder"), 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(host.Workspace(), ".fake-folder", "spec.md"), []byte(strings.Join(args, " ")+"\n"), 0o644); err != nil {
+ return err
+ }
+ host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "made it"})
+ return nil
+ }
+ },
+ }},
+ }
+}
+
+// hostWithFolderChild is [hostWithRealChild] for the fake that works in its
+// folder, and it answers what the run printed on stdout and on stderr.
+func hostWithFolderChild(t *testing.T) (*carriedFunnel, *lockedBuffer, *lockedBuffer) {
+ t.Helper()
+ restore := builtin.Override([]delegate.Delegate{fakeFolderProgram()})
+ t.Cleanup(restore)
+ t.Setenv(carriedChildEnv, "folder")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+ calling := &carriedFunnel{cost: 0.001}
+ previousRoad, previousOut, previousErr, previousGrace := carriedModels, carriedStdout, carriedStderr, carriedGrace
+ carriedModels = func() (carriedRoad, error) {
+ return carriedRoad{completerFor: calling.completerFor, seat: "seat/model"}, nil
+ }
+ printed, said := &lockedBuffer{}, &lockedBuffer{}
+ carriedStdout, carriedStderr = printed, said
+ carriedGrace = 5 * time.Second
+ t.Cleanup(func() {
+ carriedModels, carriedStdout, carriedStderr, carriedGrace = previousRoad, previousOut, previousErr, previousGrace
+ })
+ return calling, printed, said
+}
+
+// shellRepo is a repository with one commit on `main`, the way a person's
+// project stands.
+func shellRepo(t *testing.T) string {
+ t.Helper()
+ repo := t.TempDir()
+ for _, args := range [][]string{
+ {"init", "-q", "-b", "main"},
+ {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "first"},
+ } {
+ shellGit(t, repo, args...)
+ }
+ return repo
+}
+
+// shellGit runs one git command in dir and answers what it printed.
+func shellGit(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ command := exec.Command("git", args...)
+ command.Dir = dir
+ out, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// A SHELL RUN IN A PLAIN FOLDER NO LONGER FAILS: the program is told on its
+// line that it works without git, its work is left in the folder, its notes
+// are moved into the run's record folder, and the last lines say so.
+func TestAShellRunInAPlainFolderIsToldSoAndDoesNotFail(t *testing.T) {
+ _, printed, _ := hostWithFolderChild(t)
+ folder := t.TempDir()
+ err := runCarried(fakeFolderProgram(), []string{"--dir", folder, "make a file"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed)
+ }
+ out := printed.String()
+ if !strings.Contains(out, " folder · in place") {
+ t.Fatalf("the program was not told it works without git:\n%s", out)
+ }
+ if _, err := os.Stat(filepath.Join(folder, "made.txt")); err != nil {
+ t.Fatalf("the work is not in the folder: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) {
+ t.Fatalf("a plain folder was made a repository: %v", err)
+ }
+ record := newestRecordOf(t, fakeFolder)
+ if _, err := os.Stat(filepath.Join(record, fakeFolder, "spec.md")); err != nil {
+ t.Fatalf("the program's notes are not in the run's record folder: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(folder, ".fake-folder")); !os.IsNotExist(err) {
+ t.Fatalf("the program's notes were left in the folder: %v", err)
+ }
+ want := " its work is in " + folder + ", which has no git history, so nothing was committed; its notes (.fake-folder/) are kept in " + filepath.Join(record, fakeFolder)
+ if !strings.Contains(out, want) {
+ t.Fatalf("the last lines do not say where the work is:\n%s\nwant %q", out, want)
+ }
+}
+
+// A SHELL RUN IN A REPOSITORY WORKS ON A BRANCH OF ITS OWN, and the person's
+// branch never moves: the program's work is committed on its branch, which is
+// left checked out, and the last lines say how to go back.
+func TestAShellRunInARepositoryWorksOnABranchOfItsOwn(t *testing.T) {
+ _, printed, _ := hostWithFolderChild(t)
+ repo := shellRepo(t)
+ base := shellGit(t, repo, "rev-parse", "main")
+ err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed)
+ }
+ out := printed.String()
+ branch := shellGit(t, repo, "branch", "--show-current")
+ if !strings.HasPrefix(branch, "task/make-a-file-") {
+ t.Fatalf("the checkout is on %q, want the run's own branch left checked out", branch)
+ }
+ if !strings.Contains(out, fakeFolder+" · working in "+repo+", on its own branch "+branch) || !strings.Contains(out, " folder · git") {
+ t.Fatalf("the run did not say it works on its own branch with git:\n%s", out)
+ }
+ if tip := shellGit(t, repo, "rev-parse", "main"); tip != base {
+ t.Fatalf("the person's branch moved from %s to %s", base, tip)
+ }
+ if files := shellGit(t, repo, "ls-tree", "--name-only", branch); files != "made.txt" {
+ t.Fatalf("the run's branch holds %q, want its work and none of its notes", files)
+ }
+ if subject := shellGit(t, repo, "log", "-1", "--format=%s", branch); subject != "make a file" {
+ t.Fatalf("the commit of its work is %q, want the brief's words", subject)
+ }
+ if status := shellGit(t, repo, "status", "--porcelain"); status != "" {
+ t.Fatalf("the run left changes that are not committed:\n%s", status)
+ }
+ if !strings.Contains(out, " its work is on the branch "+branch+" in "+repo+", 1 file, and that branch is checked out there; your branch main is as it was") {
+ t.Fatalf("the last lines do not say where the work is:\n%s", out)
+ }
+}
+
+// A SHELL RUN ON A CHECKOUT WITH CHANGES THAT ARE NOT COMMITTED IS REFUSED
+// before anything is started or spent, with the paths named.
+func TestAShellRunIsRefusedACheckoutWithChangesThatAreNotCommitted(t *testing.T) {
+ calling, printed, said := hostWithFolderChild(t)
+ repo := shellRepo(t)
+ if err := os.WriteFile(filepath.Join(repo, "draft.md"), []byte("mine\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"})
+ if code := exitCodeOf(err); code != int(exitCannotRun) {
+ t.Fatalf("left with %d, want the rung for a run that could not start", code)
+ }
+ if want := "error: " + repo + " has changes that are not committed (draft.md); commit or stash them, then ask again"; !strings.Contains(said.String(), want) {
+ t.Fatalf("the refusal = %q, want %q", said.String(), want)
+ }
+ if len(calling.seen()) != 0 || printed.String() != "" {
+ t.Fatalf("a refused run did something: %d calls, printed %q", len(calling.seen()), printed.String())
+ }
+ if branch := shellGit(t, repo, "branch", "--show-current"); branch != "main" {
+ t.Fatalf("a refused checkout was switched to %q", branch)
+ }
+}
+
+// A SHELL RUN'S CHILD IS TOLD WHAT CODEAF DECIDED ABOUT ITS FOLDER: the
+// program's own flags for a folder without git, and the repository's root in
+// place of the person's --dir when that named a folder inside it.
+func TestAShellRunsChildLineCarriesItsFolder(t *testing.T) {
+ program := fakeFolderProgram()
+ inv, err := delegate.Parse(program, []string{"--dir", "/r/repo/sub", "fix", "it"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ child := carriedInFolder(carriedChildLine(inv), inv, &session.ProgramFolder{Dir: "/r/repo", Branch: "task/fix-it-abc123"})
+ if got, want := strings.Join(child, " "), fakeFolder+" --json --dir /r/repo fix it"; got != want {
+ t.Fatalf("the child line = %q, want %q", got, want)
+ }
+ plain, err := delegate.Parse(program, []string{"--dir", "/r/plain", "fix", "it"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ child = carriedInFolder(carriedChildLine(plain), plain, &session.ProgramFolder{Dir: "/r/plain"})
+ if got, want := strings.Join(child, " "), fakeFolder+" --json --in-place --dir /r/plain fix it"; got != want {
+ t.Fatalf("the child line = %q, want %q", got, want)
+ }
+ again, err := delegate.Parse(program, child[1:], &bytes.Buffer{})
+ if err != nil || again.Workspace != "/r/plain" || again.Brief() != "fix it" {
+ t.Fatalf("the child reads %+v (%v)", again, err)
+ }
+}
+
+// newestRecordOf is the most recent shell run's record folder for a program.
+func newestRecordOf(t *testing.T, name string) string {
+ t.Helper()
+ matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot(name), "*"))
+ if len(matches) == 0 {
+ t.Fatal("the shell run kept no record folder")
+ }
+ newest := matches[0]
+ for _, match := range matches[1:] {
+ if match > newest {
+ newest = match
+ }
+ }
+ return newest
+}
+
+// A SHELL RUN FINISHES ITS FOLDER BEFORE IT WAITS FOR ITS LAST PRICES. That
+// wait is up to seventy seconds, a second ctrl-c during it leaves at once,
+// and the folder used to be finished only after it: the repository was left
+// on the program's branch with its work uncommitted and nothing said. By the
+// time the model API starts closing, the work is committed and the run's
+// record says when its program ended.
+func TestAShellRunFinishesItsFolderBeforeWaitingForPrices(t *testing.T) {
+ _, printed, _ := hostWithFolderChild(t)
+ repo := shellRepo(t)
+ var atClose struct{ status, files string }
+ previous := carriedAPIClose
+ carriedAPIClose = func(api *modelapi.Server) error {
+ atClose.status = shellGit(t, repo, "status", "--porcelain")
+ atClose.files = shellGit(t, repo, "ls-tree", "--name-only", "HEAD")
+ return previous(api)
+ }
+ t.Cleanup(func() { carriedAPIClose = previous })
+ err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed)
+ }
+ if atClose.status != "" || atClose.files != "made.txt" {
+ t.Fatalf("when the API began to close the folder held %q uncommitted and %q committed, want its work committed", atClose.status, atClose.files)
+ }
+}
diff --git a/cmd/codeaf/carried_host_test.go b/cmd/codeaf/carried_host_test.go
new file mode 100644
index 0000000000..f2e78b2913
--- /dev/null
+++ b/cmd/codeaf/carried_host_test.go
@@ -0,0 +1,259 @@
+//go:build !windows
+
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// carriedFunnel is the person's model road as a test writes it: every call
+// answered with words and billed at cost, the way the provider's decode bills
+// one.
+type carriedFunnel struct {
+ mu sync.Mutex
+ cost float64
+ models []string
+}
+
+func (f *carriedFunnel) completerFor(string) modelapi.Completer { return f }
+
+func (f *carriedFunnel) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ f.mu.Lock()
+ f.models = append(f.models, request.Model)
+ f.mu.Unlock()
+ if sink := provider.BillingSinkFrom(ctx); sink != nil {
+ sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, Cost: f.cost})
+ }
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "answered " + messages[len(messages)-1].Content[0].Text}}},
+ FinishReason: "stop",
+ }}}, nil
+}
+
+func (f *carriedFunnel) seen() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.models...)
+}
+
+// hostWithRealChild carries the fake program, marks the environment so the
+// child this test binary starts runs it, and replaces the person's profile
+// with a funnel costing cost a call. It answers the funnel and what the shell
+// run prints, in a buffer that can be read while the run is still writing it
+// (chatv3_host_duty_test.go's lockedBuffer).
+func hostWithRealChild(t *testing.T, cost float64) (*carriedFunnel, *lockedBuffer) {
+ t.Helper()
+ restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()})
+ t.Cleanup(restore)
+ t.Setenv(carriedChildEnv, "1")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+ calling := &carriedFunnel{cost: cost}
+ previousRoad, previousOut, previousGrace := carriedModels, carriedStdout, carriedGrace
+ carriedModels = func() (carriedRoad, error) {
+ return carriedRoad{completerFor: calling.completerFor, seat: "seat/model"}, nil
+ }
+ printed := &lockedBuffer{}
+ carriedStdout = printed
+ carriedGrace = 5 * time.Second
+ t.Cleanup(func() { carriedModels, carriedStdout, carriedGrace = previousRoad, previousOut, previousGrace })
+ return calling, printed
+}
+
+// ledgerRowsFor is this machine's spending ledger's rows for one workspace,
+// once the writers have drained. Other tests in this binary share the ledger,
+// and a workspace of this test's own is what tells its rows apart.
+func ledgerRowsFor(t *testing.T, workspace string) []session.UsageLine {
+ t.Helper()
+ session.FlushUsage()
+ file, err := os.Open(session.UsageLedgerPath())
+ if err != nil {
+ t.Fatalf("the spending ledger was never written: %v", err)
+ }
+ defer file.Close()
+ var rows []session.UsageLine
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ var row session.UsageLine
+ if json.Unmarshal(scanner.Bytes(), &row) == nil && row.Workspace == workspace {
+ rows = append(rows, row)
+ }
+ }
+ return rows
+}
+
+// newestRecord is the most recent shell run's record folder for the fake.
+func newestRecord(t *testing.T) string {
+ t.Helper()
+ matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot(fakeCarried), "*"))
+ if len(matches) == 0 {
+ t.Fatal("the shell run kept no record folder")
+ }
+ newest := matches[0]
+ for _, match := range matches[1:] {
+ if match > newest {
+ newest = match
+ }
+ }
+ return newest
+}
+
+// A PERSON'S SHELL RUN IS THE SAME TWO PROCESSES A CHAT'S RUN IS: this process
+// serves the model API, the program runs as a real child of this executable
+// with its own command's flag carried through, every call it makes is
+// metered onto this machine's spending ledger once, the conversation is kept
+// in the run's record folder, and a person reads the stage, each step, each
+// call and the ending as lines.
+func TestAShellRunHostsTheModelAPIForARealChildAndPrintsItsWork(t *testing.T) {
+ calling, printed := hostWithRealChild(t, 0.004)
+ workspace := t.TempDir()
+ err := runCarried(fakeCarriedProgram(), []string{"--calls", "2", "--dir", workspace, "fix", "the", "flaky", "test"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed)
+ }
+ out := printed.String()
+ for _, line := range []string{
+ fakeCarried + " · working in " + workspace,
+ "implement · running",
+ " model: ask · answered question 1: fix the flaky test",
+ " openrouter/deepseek/deepseek-v4-flash-0731 · 100 in · 10 out · $0.0040",
+ "verify · pass",
+ fakeCarried + " finished: submitted and verified",
+ " " + fakeCarried + "'s model said: the test is fixed",
+ " 2 model calls · $0.0080",
+ " the run's record is in ",
+ } {
+ if !strings.Contains(out, line) {
+ t.Fatalf("the shell run never printed %q:\n%s", line, out)
+ }
+ }
+ // THE PERSON'S OWN FLAG REACHED THE PROGRAM: two questions, not one, and
+ // the ask's `openrouter/` spelling reached the funnel as the ask.
+ if models := calling.seen(); len(models) != 2 || models[0] != "openrouter/deepseek/deepseek-v4-flash-0731" {
+ t.Fatalf("the funnel was asked for %q", models)
+ }
+ rows := ledgerRowsFor(t, workspace)
+ if len(rows) != 2 || rows[0].USD != 0.004 || rows[0].Model != "openrouter/deepseek/deepseek-v4-flash-0731" || rows[0].Seat != session.SeatWorker {
+ t.Fatalf("ledger rows = %+v, want one per call", rows)
+ }
+ record := newestRecord(t)
+ turns, err := delegate.ReadTurns(record, 0)
+ if err != nil || len(turns) != 2 || turns[1].Reply != "answered question 2: fix the flaky test" || turns[1].CostUSD != 0.004 {
+ t.Fatalf("the kept conversation = %+v (%v)", turns, err)
+ }
+ // AND THE ACTIONS ARE KEPT BESIDE IT, the log a chat's run keeps: every
+ // stage, step and ending as it arrived.
+ actions, err := delegate.ReadActions(record, 0)
+ var kinds []string
+ for _, action := range actions {
+ kinds = append(kinds, action.Kind)
+ }
+ if err != nil || strings.Join(kinds, ",") != "stage,step,step,stage,end" || actions[4].Message != "submitted and verified" {
+ t.Fatalf("the kept actions = %v %+v (%v)", kinds, actions, err)
+ }
+}
+
+// WITH --json THE RECORDS PASS THROUGH AS RECORDS, and nothing a person reads
+// is mixed into them: one reader of the protocol reads the host's stdout the
+// way it reads a program's.
+func TestAShellRunWithJSONPassesTheRecordsThrough(t *testing.T) {
+ _, printed := hostWithRealChild(t, 0.001)
+ err := runCarried(fakeCarriedProgram(), []string{"--json", "--dir", t.TempDir(), "fix it"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("left with %d:\n%s", code, printed)
+ }
+ reading, readErr := delegate.Read(strings.NewReader(printed.String()), nil)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if reading.Ignored != 0 || reading.Hello == nil || reading.Steps != 1 || reading.Terminal == nil || reading.Terminal.Status != delegate.StatusPass {
+ t.Fatalf("reading = %+v, want the program's records and nothing else:\n%s", reading, printed)
+ }
+ if reading.Terminal.Claim() != "the test is fixed" {
+ t.Fatalf("the terminal's data did not pass through: %+v", reading.Terminal)
+ }
+}
+
+// A LIMIT THE PERSON SET STOPS THE PROGRAM FROM OUTSIDE: the dollar ceiling is
+// reached by the second metered call, the program is stopped, a third call is
+// refused before it is made, and the run leaves on the limit rung.
+func TestAShellRunStopsItsProgramAtTheDollarCeiling(t *testing.T) {
+ calling, printed := hostWithRealChild(t, 0.004)
+ workspace := t.TempDir()
+ err := runCarried(fakeCarriedProgram(), []string{"--max-cost", "0.005", "--calls", "4", "--wait", "--dir", workspace, "fix it"})
+ if code := exitCodeOf(err); code != int(exitLimit) {
+ t.Fatalf("left with %d, want the limit rung:\n%s", code, printed)
+ }
+ if !strings.Contains(printed.String(), fakeCarried+" was stopped at a limit you set") {
+ t.Fatalf("the ending does not name the limit:\n%s", printed)
+ }
+ if models := calling.seen(); len(models) != 2 {
+ t.Fatalf("the funnel was asked %d times, want the two calls that reached the ceiling", len(models))
+ }
+ if rows := ledgerRowsFor(t, workspace); len(rows) != 2 {
+ t.Fatalf("ledger rows = %+v", rows)
+ }
+}
+
+// A RUN WITH NO BRIEF IS NOT STARTED: nothing is spent, no child is started,
+// and the run leaves on the first rung.
+func TestAShellRunWithNoBriefStartsNothing(t *testing.T) {
+ calling, printed := hostWithRealChild(t, 0.001)
+ err := runCarried(fakeCarriedProgram(), []string{"--dir", t.TempDir()})
+ if code := exitCodeOf(err); code != int(exitCannotRun) {
+ t.Fatalf("left with %d, want the rung for a run that could not start", code)
+ }
+ if len(calling.seen()) != 0 || printed.String() != "" {
+ t.Fatalf("a run with no brief did something: %d calls, printed %q", len(calling.seen()), printed.String())
+ }
+}
+
+// CTRL-C IS A STOP: the program is sent SIGTERM, writes how it ended inside
+// its grace, and the run leaves as work that did not finish.
+func TestAShellRunIsStoppedCleanlyWhenItsContextEnds(t *testing.T) {
+ _, printed := hostWithRealChild(t, 0.001)
+ inv, err := delegate.Parse(fakeCarriedProgram(), []string{"--calls", "1", "--wait", "--dir", t.TempDir(), "fix it"}, printed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ // Once the program has asked its one question it is waiting to be
+ // stopped; that is when a person reaches for ctrl-c.
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) && !strings.Contains(printed.String(), "model: ask") {
+ time.Sleep(20 * time.Millisecond)
+ }
+ cancel()
+ }()
+ started := time.Now()
+ err = runCarriedHost(ctx, inv)
+ if code := exitCodeOf(err); code != int(exitIncomplete) {
+ t.Fatalf("left with %d, want the rung for work that did not finish:\n%s", code, printed)
+ }
+ if !strings.Contains(printed.String(), fakeCarried+" did not finish: stopped before it finished") {
+ t.Fatalf("the program's own ending did not arrive inside its grace:\n%s", printed)
+ }
+ if time.Since(started) > 8*time.Second {
+ t.Fatalf("the stop took %s; the program was not stopped by SIGTERM", time.Since(started))
+ }
+}
diff --git a/cmd/codeaf/carried_money_test.go b/cmd/codeaf/carried_money_test.go
new file mode 100644
index 0000000000..bab3f15326
--- /dev/null
+++ b/cmd/codeaf/carried_money_test.go
@@ -0,0 +1,137 @@
+//go:build !windows
+
+package main
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// carriedOwingFunnel answers at once with no usage block and owes the call's
+// receipt, which lands after a delay — the provider's own order: owed before
+// the fetch, answered after the sink has the money.
+type carriedOwingFunnel struct{ late time.Duration }
+
+func (f carriedOwingFunnel) completerFor(string) modelapi.Completer { return f }
+
+func (f carriedOwingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ done := provider.ReceiptPendingFrom(ctx)()
+ sink := provider.ReconcileSinkFrom(ctx)
+ go func() {
+ defer done()
+ time.Sleep(f.late)
+ sink(provider.Reconciled{Billed: provider.Billed{Model: request.Model, PromptTokens: 52139, CompletionTokens: 4895, Cost: 0.058188488}, Found: true})
+ }()
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "cut short"}}},
+ FinishReason: "stop",
+ }}}, nil
+}
+
+// A SHELL RUN WAITS FOR THE PRICE OF ITS LAST CALL, SAYS SO, AND FILES IT. The
+// call's receipt lands after the program has exited; the run waits for it
+// before its last lines and before this process's ledger closes, tells the
+// person why on stderr, files the row under the run, and keeps the program's
+// own clock in its record folder.
+func TestAShellRunWaitsForItsLastCallsPriceAndKeepsItsClock(t *testing.T) {
+ _, printed := hostWithRealChild(t, 0)
+ carriedModels = func() (carriedRoad, error) {
+ return carriedRoad{completerFor: carriedOwingFunnel{late: 300 * time.Millisecond}.completerFor, seat: "seat/model"}, nil
+ }
+ told := &lockedBuffer{}
+ previous := carriedStderr
+ carriedStderr = told
+ t.Cleanup(func() { carriedStderr = previous })
+ workspace := t.TempDir()
+ before := time.Now()
+ err := runCarried(fakeCarriedProgram(), []string{"--calls", "1", "--dir", workspace, "fix it"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("left with %d (%v):\n%s", code, err, printed)
+ }
+ if !strings.Contains(told.String(), "waiting up to 1m 10s for the price of 1 call that was cut short") {
+ t.Fatalf("stderr = %q, want the wait said", told.String())
+ }
+ if !strings.Contains(printed.String(), " 1 model call · $0.06") {
+ t.Fatalf("the last line does not hold the late call:\n%s", printed)
+ }
+ record := newestRecord(t)
+ rows := ledgerRowsFor(t, workspace)
+ if len(rows) != 1 || !rows[0].Reconciled || rows[0].USD != 0.058188488 || rows[0].Task != filepath.Base(record) {
+ t.Fatalf("ledger rows = %+v, want the late receipt filed under the run %q", rows, filepath.Base(record))
+ }
+ program, ok := delegate.ReadProgram(record)
+ if !ok || program.Name != fakeCarried || program.StartedAt.Before(before) || !program.EndedAt.After(program.StartedAt) ||
+ len(program.Stages) == 0 {
+ t.Fatalf("program record = %+v (%v), want its name, stages and the program's own start and end", program, ok)
+ }
+}
+
+// THE LAST LINE SAYS HOW LONG THE PROGRAM RAN, the way a person says it, and
+// leaves off a figure nobody measured rather than writing a zero. IT IS THE LAST
+// LINE EVEN THOUGH THE RUN KEPT A RECORD: every real run has a record folder by
+// its end, and the line naming it used to follow the summary, so the manual's
+// "last line" was the folder's path.
+func TestAShellRunsLastLineSaysHowLongItRan(t *testing.T) {
+ for _, row := range []struct {
+ calls int
+ spent float64
+ took time.Duration
+ want string
+ }{
+ {calls: 277, spent: 2.295385, took: 22*time.Minute + 51*time.Second, want: " 277 model calls · $2.30 · 22m 51s\n"},
+ {took: 2*time.Hour + 5*time.Minute, want: " 2h 5m\n"},
+ {calls: 1, took: 400 * time.Millisecond, want: " 1 model call\n"},
+ } {
+ printed := &lockedBuffer{}
+ inv := &delegate.Invocation{Program: fakeCarriedProgram(), Workspace: t.TempDir()}
+ record := t.TempDir()
+ view := newCarriedView(printed, inv, record)
+ view.calls = row.calls
+ view.Terminal(delegate.Terminal{Status: delegate.StatusPass, Message: "done"})
+ _ = view.end(delegate.Result{}, nil, false, row.spent, row.took)
+ if !strings.HasSuffix(printed.String(), row.want) {
+ t.Fatalf("printed %q, want it to end %q", printed.String(), row.want)
+ }
+ if !strings.Contains(printed.String(), " the run's record is in "+record+"\n") {
+ t.Fatalf("printed %q, want the record folder named before the last line", printed.String())
+ }
+ }
+}
+
+// A SHELL RUN'S TIME IS THE PROGRAM'S, NOT THE DRAIN'S. The launch returns only
+// once the program's stdout is drained, and a helper the program left holding
+// stdout keeps that open for up to the grace after the program itself exited.
+// The shell took its end after the launch returned, so the same program read up
+// to fifteen seconds longer from a shell than from a conversation, whose worker
+// already ends the clock at the process's own exit.
+func TestAShellRunsTimeEndsWhenTheProgramExitedAndNotWhenItsOutputDrained(t *testing.T) {
+ _, printed := hostWithRealChild(t, 0.01)
+ const linger = 2 * time.Second
+ before := time.Now()
+ err := runCarried(fakeCarriedProgram(), []string{"--calls", "1", "--linger", linger.String(), "--dir", t.TempDir(), "fix it"})
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("left with %d (%v):\n%s", code, err, printed)
+ }
+ if waited := time.Since(before); waited < linger {
+ t.Fatalf("the run returned after %v, before the helper let go of stdout at %v", waited, linger)
+ }
+ program, ok := delegate.ReadProgram(newestRecord(t))
+ if !ok || program.EndedAt.Before(program.StartedAt) {
+ t.Fatalf("program record = %+v (%v), want the program's own start and end", program, ok)
+ }
+ if ran := program.EndedAt.Sub(program.StartedAt); ran >= linger {
+ t.Fatalf("the record says the program ran %v, which is the drain's %v and not the process's", ran, linger)
+ }
+}
diff --git a/cmd/codeaf/carried_seniordev_test.go b/cmd/codeaf/carried_seniordev_test.go
new file mode 100644
index 0000000000..1f054c1727
--- /dev/null
+++ b/cmd/codeaf/carried_seniordev_test.go
@@ -0,0 +1,207 @@
+//go:build !windows
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// seniorDevModel is senior-dev's side of one scripted conversation, played by
+// codeaf's funnel instead of a model: write the feature, write the checklist,
+// submit, and say it is done — the conversation internal/seniordev's own test
+// plays against a server that imitates the model API. Here the API is the
+// real one, and every call through it is billed.
+type seniorDevModel struct {
+ mu sync.Mutex
+ calls int
+ keys []string
+}
+
+func (m *seniorDevModel) completerFor(string) modelapi.Completer { return m }
+
+func (m *seniorDevModel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ m.mu.Lock()
+ m.calls++
+ call := m.calls
+ m.keys = append(m.keys, provider.CacheKeyFrom(ctx))
+ m.mu.Unlock()
+ if sink := provider.BillingSinkFrom(ctx); sink != nil {
+ sink(provider.Billed{Model: request.Model, PromptTokens: 300, CompletionTokens: 20, Cost: 0.002})
+ }
+ tool := func(name string, arguments map[string]any) (*ai.Response, error) {
+ encoded, _ := json.Marshal(arguments)
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{
+ // ONE ID PER CALL, as a model gives them: senior-dev reports a
+ // finished call once per id, so two writes under one id read as
+ // one step.
+ ID: fmt.Sprintf("call-%s-%d", name, call), Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: string(encoded)},
+ }}},
+ FinishReason: "tool_calls",
+ }}}, nil
+ }
+ switch call {
+ case 1:
+ return tool("write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"})
+ case 2:
+ return tool("write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"})
+ case 3:
+ return tool("submit", map[string]any{
+ "reason": "feature.txt now holds the feature", "evidence": "make test exits 0", "checklist_satisfied": true,
+ })
+ }
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "Done."}}}, FinishReason: "stop",
+ }}}, nil
+}
+
+// SENIOR-DEV ITSELF, THROUGH THE WHOLE ROAD: a person's shell run of the
+// program this build carries serves it the real model API, starts it as a real
+// child of this executable, and senior-dev — speaking its own OpenRouter
+// dialect over a real socket, streaming — works a scripted task in a real
+// repository to a passing ending. Every call is metered onto this machine's
+// ledger once, the conversation is kept, and the work is in the tree.
+func TestSeniorDevWorksATaskThroughTheShellHostsModelAPI(t *testing.T) {
+ if testing.Short() {
+ t.Skip("drives the real senior-dev engine")
+ }
+ program, carried := builtin.Find("senior-dev")
+ if !carried {
+ t.Skip("this build carries no senior-dev")
+ }
+ workspace := seniorDevWorkspace(t)
+ t.Setenv(carriedChildEnv, "real")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+ model := &seniorDevModel{}
+ previousRoad, previousOut, previousGrace := carriedModels, carriedStdout, carriedGrace
+ carriedModels = func() (carriedRoad, error) { return carriedRoad{completerFor: model.completerFor}, nil }
+ printed := &lockedBuffer{}
+ carriedStdout = printed
+ carriedGrace = 5 * time.Second
+ t.Cleanup(func() { carriedModels, carriedStdout, carriedGrace = previousRoad, previousOut, previousGrace })
+
+ err := runCarried(program, []string{"--high", "openrouter/fixture/vendor-model", "--dir", workspace, "--", "Add", "the", "feature."})
+ out := printed.String()
+ if code := exitCodeOf(err); code != 0 {
+ stderr := ""
+ if matches, _ := filepath.Glob(filepath.Join(newestSeniorDevRecord(t), carriedStderrName)); len(matches) > 0 {
+ data, _ := os.ReadFile(matches[0])
+ stderr = string(data)
+ }
+ t.Fatalf("senior-dev's shell run left with %d:\n%s\nits stderr:\n%s", code, out, stderr)
+ }
+ for _, want := range []string{"senior-dev · working in " + workspace, "senior-dev finished", "senior-dev's model said: feature.txt now holds the feature", " · 300 in · 20 out · $0.0020"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("the shell run never printed %q:\n%s", want, out)
+ }
+ }
+ if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" {
+ t.Fatalf("the work is not in the tree: %q %v", content, err)
+ }
+ model.mu.Lock()
+ calls, keys := model.calls, append([]string(nil), model.keys...)
+ model.mu.Unlock()
+ if calls < 4 {
+ t.Fatalf("senior-dev made %d calls through the API, want the scripted four", calls)
+ }
+ for _, key := range keys {
+ if key == "" {
+ t.Fatalf("a call lost senior-dev's own prompt_cache_key: %q", keys)
+ }
+ }
+ if rows := ledgerRowsFor(t, workspace); len(rows) != calls {
+ t.Fatalf("%d ledger rows for %d calls, want exactly one each", len(rows), calls)
+ }
+ turns, err := delegate.ReadTurns(newestSeniorDevRecord(t), 0)
+ if err != nil || len(turns) != calls {
+ t.Fatalf("the kept conversation holds %d turns (%v), want one per call", len(turns), err)
+ }
+ var submitted bool
+ for _, turn := range turns {
+ for _, use := range turn.Calls {
+ submitted = submitted || use.Name == "submit"
+ }
+ }
+ if !submitted || turns[0].Thread == delegate.MainThread {
+ t.Fatalf("the conversation lacks the submit or senior-dev's own thread: %+v", turns)
+ }
+}
+
+// seniorDevWorkspace is the hermetic world senior-dev's own test runs in
+// (internal/seniordev's hermeticRun): nothing of the machine's configuration,
+// its model catalog on disk and no fetch, and a git repository whose build and
+// tests pass.
+func seniorDevWorkspace(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
+ t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir())
+ t.Setenv("SENIOR_DEV_CONFIG", "")
+ t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "")
+ t.Setenv("SENIOR_DEV_PERMISSION", "")
+ t.Setenv("SENIOR_DEV_NET", "allow")
+ t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir())
+ t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "1")
+ catalog, err := filepath.Abs(filepath.Join("..", "..", "internal", "seniordev", "modelsdev", "testdata", "catalog.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(catalog); err != nil {
+ t.Skipf("senior-dev's fixture catalog is not where its test keeps it: %v", err)
+ }
+ t.Setenv("SENIOR_DEV_MODELS_PATH", catalog)
+ workspace := t.TempDir()
+ for name, content := range map[string]string{"README.md": "base\n", "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n"} {
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, args := range [][]string{
+ {"init", "-q", "-b", "main"},
+ {"add", "README.md", "Makefile"},
+ {"-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "base"},
+ } {
+ command := exec.Command("git", args...)
+ command.Dir = workspace
+ if out, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ return workspace
+}
+
+// newestSeniorDevRecord is the most recent shell run's record folder for
+// senior-dev.
+func newestSeniorDevRecord(t *testing.T) string {
+ t.Helper()
+ matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot("senior-dev"), "*"))
+ newest := ""
+ for _, match := range matches {
+ if match > newest {
+ newest = match
+ }
+ }
+ return newest
+}
diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go
new file mode 100644
index 0000000000..4639ee19cb
--- /dev/null
+++ b/cmd/codeaf/carried_seniordev_worker_test.go
@@ -0,0 +1,302 @@
+//go:build !windows
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ runengine "github.com/Agent-Field/codeaf/internal/run"
+ "github.com/Agent-Field/codeaf/internal/seniordev/app"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// SENIOR-DEV ITSELF, THE CHAT'S WAY: the road `/senior-dev ` takes once
+// the conversation has opened its run — the run's worker serves the program the
+// real model API, starts it as a real child of this executable with the line
+// the chat hands it and an environment with no key in it, and senior-dev works
+// a scripted task in a real repository to a passing ending. Every call it makes
+// is metered once: one spend row on the task, one ledger row, one turn of the
+// conversation the task page draws, and the program record the page names it
+// by, ceiling included.
+//
+// The shell's road is TestSeniorDevWorksATaskThroughTheShellHostsModelAPI;
+// this one is the worker's, which is where the chat's money, its page and its
+// ceiling are kept.
+func TestSeniorDevWorksATaskAsTheChatsRunWorker(t *testing.T) {
+ if testing.Short() {
+ t.Skip("drives the real senior-dev engine")
+ }
+ program, carried := builtin.Find("senior-dev")
+ if !carried {
+ t.Skip("this build carries no senior-dev")
+ }
+ workspace := seniorDevWorkspace(t)
+ seniorDevCatalogWithItsOwnPool(t)
+ t.Setenv(carriedChildEnv, "real")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+ // The key a program must never see, planted where a careless launch would
+ // hand it on.
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-the-chat-run-must-not-hand-this-on")
+
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.")
+ if err != nil {
+ t.Fatalf("open plan store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ storeDir := filepath.Dir(store.Path())
+ root := store.RootID()
+
+ model := &seniorDevModel{}
+ ledger := filepath.Join(t.TempDir(), "usage.jsonl")
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ const ceiling = 1.0
+ worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{
+ Exe: self,
+ Grace: 5 * time.Second,
+ CompleterFor: func(string) session.Completer { return model },
+ Ledger: ledger,
+ }, ceiling, 0)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ report, err := worker.Run(ctx, *store.Task(root))
+ taskDir := plandb.TaskDir(storeDir, root)
+ if err != nil {
+ stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log"))
+ t.Fatalf("the chat's run of senior-dev failed: %v\nits stderr:\n%s", err, stderr)
+ }
+ if !strings.Contains(report.Result, "senior-dev's model said: feature.txt now holds the feature") {
+ t.Fatalf("the run's result = %q, want senior-dev's own ending with its claim", report.Result)
+ }
+ if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" {
+ t.Fatalf("the work is not in the copy: %q %v", content, err)
+ }
+
+ model.mu.Lock()
+ calls := model.calls
+ model.mu.Unlock()
+ if calls < 4 {
+ t.Fatalf("senior-dev made %d calls through the API, want the scripted four", calls)
+ }
+ // ONE ROW PER CALL, AND NOTHING AT THE END: the page and the rail sum
+ // these, so an end-of-run row would count the money twice.
+ spent := store.SpendSummary().ByModel["delegate/senior-dev"]
+ if spent.Calls != calls || math.Abs(spent.USD-0.002*float64(calls)) > 1e-9 {
+ t.Fatalf("the task holds %d spend rows for $%.4f, want one per call (%d) at $0.002 each", spent.Calls, spent.USD, calls)
+ }
+ if math.Abs(report.USD-spent.USD) > 1e-9 {
+ t.Fatalf("the run reported $%.4f and the task holds $%.4f; they must be one figure", report.USD, spent.USD)
+ }
+ session.FlushUsage()
+ if data, err := os.ReadFile(ledger); err != nil || strings.Count(strings.TrimSpace(string(data)), "\n")+1 != calls {
+ t.Fatalf("the ledger holds %q (%v), want one row per call", data, err)
+ }
+ turns, err := delegate.ReadTurns(taskDir, 0)
+ if err != nil || len(turns) != calls {
+ t.Fatalf("the task's conversation holds %d turns (%v), want one per call", len(turns), err)
+ }
+ record, ok := delegate.ReadProgram(taskDir)
+ if !ok || record.Name != "senior-dev" || len(record.Stages) == 0 || record.CeilingUSD != ceiling {
+ t.Fatalf("the program record = %+v %v, want senior-dev, its stages and the run's ceiling", record, ok)
+ }
+ // THE TASK KEEPS senior-dev's ACTIONS, and senior-dev's own words read them
+ // under the steps of its process: the brief written down as its spec, the
+ // work, the hand-in, each command of its own check and its ending.
+ actions, err := delegate.ReadActions(taskDir, 0)
+ if err != nil || len(actions) == 0 || actions[len(actions)-1].Kind != delegate.ActionEnd {
+ t.Fatalf("the task's action log = %+v (%v), want every record and the ending last", actions, err)
+ }
+ read, steps := program.Reader(), map[string]bool{}
+ for _, action := range actions {
+ if shown, ok := read(action); ok && shown.Step != "" {
+ steps[shown.Step] = true
+ }
+ }
+ for _, want := range []string{"setup", "spec", "checklist", "implement", "submit", "verify", "finish"} {
+ if !steps[want] {
+ t.Errorf("no action was read under %q: %v", want, steps)
+ }
+ }
+ // AND NO KEY WAS HANDED ON: the child's stderr is the program's own words,
+ // and the planted key is nowhere in them.
+ if stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log")); strings.Contains(string(stderr), "the-chat-run-must-not-hand-this-on") {
+ t.Fatal("the planted key reached senior-dev's process")
+ }
+}
+
+// A FOLDER WITH NO GIT HISTORY IS WORKED IN WHERE IT IS. The chat reads the
+// folder before it starts the program and, finding no history to copy from,
+// hands senior-dev its own flag for that (seniordev.Program's PlainFolder) on
+// the line the run's worker builds. senior-dev then works the same scripted
+// task to the same passing ending, and leaves the folder as plain as it found
+// it: no repository is made in somebody's folder behind their back.
+func TestSeniorDevWorksAPlainFolderAsTheChatsRunWorker(t *testing.T) {
+ if testing.Short() {
+ t.Skip("drives the real senior-dev engine")
+ }
+ program, carried := builtin.Find("senior-dev")
+ if !carried {
+ t.Skip("this build carries no senior-dev")
+ }
+ workspace := seniorDevWorkspace(t)
+ if err := os.RemoveAll(filepath.Join(workspace, ".git")); err != nil {
+ t.Fatal(err)
+ }
+ seniorDevCatalogWithItsOwnPool(t)
+ t.Setenv(carriedChildEnv, "real")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.")
+ if err != nil {
+ t.Fatalf("open plan store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ model := &seniorDevModel{}
+ worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{
+ Exe: self,
+ Grace: 5 * time.Second,
+ CompleterFor: func(string) session.Completer { return model },
+ Ledger: filepath.Join(t.TempDir(), "usage.jsonl"),
+ PlainFolder: true,
+ }, 1.0, 0)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ report, err := worker.Run(ctx, *store.Task(store.RootID()))
+ if err != nil {
+ stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log"))
+ t.Fatalf("senior-dev on a plain folder failed: %v\nits stderr:\n%s", err, stderr)
+ }
+ if !strings.Contains(report.Result, "feature.txt now holds the feature") {
+ t.Fatalf("the run's result = %q, want senior-dev's own passing ending", report.Result)
+ }
+ if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" {
+ t.Fatalf("the work is not in the folder: %q %v", content, err)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) {
+ t.Fatalf("the plain folder was made into a repository: %v", err)
+ }
+}
+
+// THE CREW'S MODEL IS THE ONE ASKED FOR. The worker hands senior-dev the
+// conversation's crew; senior-dev routes on the crew's working seat, and every
+// call the model API serves names it — none of senior-dev's own list, which
+// this catalog does not even carry, so a call on it would fail the run.
+func TestSeniorDevWorksOnTheConversationsCrew(t *testing.T) {
+ if testing.Short() {
+ t.Skip("drives the real senior-dev engine")
+ }
+ program, carried := builtin.Find("senior-dev")
+ if !carried {
+ t.Skip("this build carries no senior-dev")
+ }
+ workspace := seniorDevWorkspace(t)
+ t.Setenv(carriedChildEnv, "real")
+ t.Setenv("DO_NOT_TRACK", "1")
+ t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
+
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.")
+ if err != nil {
+ t.Fatalf("open plan store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ model := &seniorDevModel{}
+ var asked sync.Map
+ worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{
+ Exe: self,
+ Grace: 5 * time.Second,
+ CompleterFor: func(name string) session.Completer {
+ asked.Store(name, true)
+ return model
+ },
+ Ledger: filepath.Join(t.TempDir(), "usage.jsonl"),
+ Crew: delegate.Crew{Hands: "fixture/vendor-model", Brain: "fixture/vendor-model"},
+ }, 1.0, 0)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ report, err := worker.Run(ctx, *store.Task(store.RootID()))
+ if err != nil {
+ stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log"))
+ t.Fatalf("senior-dev on the crew's model failed: %v\nits stderr:\n%s", err, stderr)
+ }
+ if !strings.Contains(report.Result, "feature.txt now holds the feature") {
+ t.Fatalf("the run's result = %q, want senior-dev's own passing ending", report.Result)
+ }
+ var names []string
+ asked.Range(func(key, _ any) bool { names = append(names, key.(string)); return true })
+ if len(names) == 0 {
+ t.Fatal("no call reached the model API")
+ }
+ for _, name := range names {
+ if !strings.Contains(name, "fixture/vendor-model") {
+ t.Fatalf("a call asked for %q; want every call on the crew's model, asked %q", name, names)
+ }
+ }
+}
+
+// seniorDevCatalogWithItsOwnPool points senior-dev at a model catalog that
+// carries its OWN default pool. The chat hands the program no `--high` — its
+// line is the default command and the shared flags only — so senior-dev asks
+// for the models it ships with, and it sizes its calls from the catalog's
+// entry for each. The fixture's one model is copied under every pool id, so
+// the run is hermetic and still the one a person's `/senior-dev` starts.
+func seniorDevCatalogWithItsOwnPool(t *testing.T) {
+ t.Helper()
+ data, err := os.ReadFile(os.Getenv("SENIOR_DEV_MODELS_PATH"))
+ if err != nil {
+ t.Fatalf("read senior-dev's fixture catalog: %v", err)
+ }
+ var catalog map[string]map[string]any
+ if err := json.Unmarshal(data, &catalog); err != nil {
+ t.Fatal(err)
+ }
+ service := catalog["openrouter"]
+ models, _ := service["models"].(map[string]any)
+ template, ok := models["fixture/vendor-model"].(map[string]any)
+ if !ok {
+ t.Fatal("the fixture catalog lost its vendor model")
+ }
+ for _, id := range strings.Split(app.DefaultHighModels, ",") {
+ id = strings.TrimPrefix(strings.TrimSpace(id), "openrouter/")
+ entry := map[string]any{}
+ for key, value := range template {
+ entry[key] = value
+ }
+ entry["id"] = id
+ models[id] = entry
+ }
+ encoded, err := json.Marshal(catalog)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "catalog.json")
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("SENIOR_DEV_MODELS_PATH", path)
+}
diff --git a/cmd/codeaf/carried_signal_test.go b/cmd/codeaf/carried_signal_test.go
new file mode 100644
index 0000000000..750cfeaef1
--- /dev/null
+++ b/cmd/codeaf/carried_signal_test.go
@@ -0,0 +1,90 @@
+//go:build !windows
+
+package main
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// carriedSecondSignalEnv marks the process this file's test starts as the one
+// that is signalled.
+const carriedSecondSignalEnv = "CODEAF_TEST_CARRIED_SECOND_SIGNAL"
+
+// A SECOND CTRL-C LEAVES A SHELL RUN AT ONCE. The first one stops the run, and
+// the run then waits for the program's grace, its last calls and the price of
+// a call the stop cut short — up to about a minute and a half. The signals were
+// held for all of it, so a person who pressed ctrl-c again was ignored. The
+// first signal now gives the terminal its ordinary ctrl-c back.
+//
+// It is proved in a process of its own, because the proof is that process
+// dying of the second signal.
+func TestASecondInterruptLeavesAShellRunAtOnce(t *testing.T) {
+ if os.Getenv(carriedSecondSignalEnv) == "1" {
+ ctx, stop := carriedSignals()
+ defer stop()
+ _ = syscall.Kill(os.Getpid(), syscall.SIGINT)
+ select {
+ case <-ctx.Done():
+ case <-time.After(5 * time.Second):
+ os.Exit(3)
+ }
+ // The release runs beside the cancellation; give it a moment.
+ time.Sleep(200 * time.Millisecond)
+ _ = syscall.Kill(os.Getpid(), syscall.SIGINT)
+ time.Sleep(5 * time.Second)
+ // Still here: the second ctrl-c was swallowed.
+ os.Exit(0)
+ }
+ command := exec.Command(os.Args[0], "-test.run=^TestASecondInterruptLeavesAShellRunAtOnce$")
+ command.Env = append(os.Environ(), carriedSecondSignalEnv+"=1")
+ began := time.Now()
+ err := command.Run()
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) {
+ t.Fatalf("the signalled process ended with %v after %v, want it killed by the second ctrl-c", err, time.Since(began))
+ }
+ status, ok := exit.Sys().(syscall.WaitStatus)
+ if !ok || !status.Signaled() || status.Signal() != syscall.SIGINT {
+ t.Fatalf("the signalled process ended %v after %v, want it killed by the second ctrl-c", exit, time.Since(began))
+ }
+}
+
+// carriedHangupEnv marks the process the hangup test starts as the one that
+// is hung up on.
+const carriedHangupEnv = "CODEAF_TEST_CARRIED_HANGUP"
+
+// A HANGUP STOPS A SHELL RUN THE WAY CTRL-C DOES. A closed terminal or a
+// dropped ssh connection sent SIGHUP, which nothing caught: the host died on
+// the spot with its program still working and its folder left unfinished.
+// Now the first hangup ends the run's context — the program is stopped and the
+// folder finished — and a second, which a shell passes on to its jobs as it
+// exits, is not allowed to kill that finishing halfway.
+//
+// It is proved in a process of its own, because the failure is that process
+// dying of the signal.
+func TestAHangupStopsAShellRunLikeCtrlC(t *testing.T) {
+ if os.Getenv(carriedHangupEnv) == "1" {
+ ctx, stop := carriedSignals()
+ defer stop()
+ _ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
+ select {
+ case <-ctx.Done():
+ case <-time.After(5 * time.Second):
+ os.Exit(3)
+ }
+ time.Sleep(200 * time.Millisecond)
+ _ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
+ time.Sleep(500 * time.Millisecond)
+ os.Exit(0)
+ }
+ command := exec.Command(os.Args[0], "-test.run=^TestAHangupStopsAShellRunLikeCtrlC$")
+ command.Env = append(os.Environ(), carriedHangupEnv+"=1")
+ if err := command.Run(); err != nil {
+ t.Fatalf("the hung-up process ended with %v, want it to hear the hangup as a stop and live through a second", err)
+ }
+}
diff --git a/cmd/codeaf/carried_test.go b/cmd/codeaf/carried_test.go
new file mode 100644
index 0000000000..f225a55608
--- /dev/null
+++ b/cmd/codeaf/carried_test.go
@@ -0,0 +1,268 @@
+package main
+
+import (
+ "bytes"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+ "github.com/Agent-Field/codeaf/internal/manual"
+ "github.com/Agent-Field/codeaf/internal/modelsource"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// carryFake puts the fake program on the build's list for one test.
+func carryFake(t *testing.T) {
+ t.Helper()
+ restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()})
+ t.Cleanup(restore)
+}
+
+// EVERY CARRIED PROGRAM IS ON THE FRONT PAGE, inside the page's two laws: its
+// own group after the work you hand codeaf, its line and its summary, eighty
+// cells at most, and the whole page still inside its line cap.
+func TestTheFrontPageListsEveryCarriedProgramInsideTheLaws(t *testing.T) {
+ carryFake(t)
+ out, errs := captureUsage(t)
+ if err := usage(nil); err != nil {
+ t.Fatal(err)
+ }
+ printed := out.String()
+ for _, want := range []string{carriedHeading, " codeaf " + fakeCarried + ` ""`, "a program the tests carry"} {
+ if !strings.Contains(printed, want) {
+ t.Fatalf("`codeaf --help` does not carry %q:\n%s", want, printed)
+ }
+ }
+ group, work, look := strings.Index(printed, carriedHeading), strings.Index(printed, "Hand it work"), strings.Index(printed, "Look at what happened")
+ if !(work < group && group < look) {
+ t.Fatalf("the carried group is at %d, want it between the work group (%d) and what happened (%d)", group, work, look)
+ }
+ pageFits(t, printed, 1)
+ if errs.Len() != 0 {
+ t.Fatalf("`codeaf --help` wrote to stderr:\n%s", errs.String())
+ }
+ // A BUILD THAT CARRIES NOTHING DRAWS NO HEADING OVER NOTHING.
+ restore := builtin.Override(nil)
+ defer restore()
+ if page := frontPage(); strings.Contains(page, carriedHeading) || page != usageText {
+ t.Fatal("a build that carries no program still draws the carried group")
+ }
+}
+
+// AND THE PAGE THIS BUILD REALLY PRINTS keeps the same laws with the programs
+// it really carries — which is where a real program's long summary would show.
+func TestTheFrontPageFitsWithTheProgramsThisBuildCarries(t *testing.T) {
+ out, _ := captureUsage(t)
+ if err := usage(nil); err != nil {
+ t.Fatal(err)
+ }
+ pageFits(t, out.String(), len(builtin.All()))
+ for _, program := range builtin.All() {
+ if !strings.Contains(out.String(), "codeaf "+program.Name+" ") {
+ t.Errorf("`codeaf --help` never names `codeaf %s`, a program this build carries", program.Name)
+ }
+ }
+}
+
+// carriedPageLines is what the carried group may cost the front page on top
+// of [helpLineCap]: its heading and the blank line under it, and TWO LINES FOR
+// EACH PROGRAM — its synopsis and a summary that fits one line. The table
+// itself sits at its cap, and the cap moves by exactly what a feature adds
+// (helpLineCap's own rule); this is that move, fixed per program, so a program
+// whose summary needs a second line fails here, and the answer is a shorter
+// summary rather than a longer page.
+func carriedPageLines(programs int) int {
+ if programs == 0 {
+ return 0
+ }
+ return 2 + 2*programs
+}
+
+// pageFits is the front page's two laws: eighty cells a line, and the cap with
+// the carried programs' own fixed allowance.
+func pageFits(t *testing.T, printed string, programs int) {
+ t.Helper()
+ lines := strings.Split(strings.TrimRight(printed, "\n"), "\n")
+ for at, line := range lines {
+ if drawn := ansi.StringWidth(line); drawn > helpWidth {
+ t.Errorf("`codeaf --help` line %d draws %d cells: %q", at+1, drawn, line)
+ }
+ }
+ if cap := helpLineCap + carriedPageLines(programs); len(lines) > cap {
+ t.Errorf("`codeaf --help` is %d lines with %d carried programs on it, past the %d-line cap", len(lines), programs, cap)
+ }
+}
+
+// ASKING A PROGRAM FOR HELP IS NOT A FAILURE, through the one dispatch a
+// person's line takes: its help on stdout, exit zero, nothing on stderr.
+func TestACarriedProgramsHelpIsNotAFailure(t *testing.T) {
+ carryFake(t)
+ for _, line := range [][]string{{"-h"}, {"--help"}, {"help"}, {"run", "--help"}} {
+ printed := &bytes.Buffer{}
+ previous := carriedStdout
+ carriedStdout = printed
+ saved := os.Args
+ os.Args = append([]string{"codeaf", fakeCarried}, line...)
+ err := run()
+ os.Args, carriedStdout = saved, previous
+ if code := exitCodeOf(err); code != 0 {
+ t.Fatalf("`codeaf %s %s` left with %d", fakeCarried, strings.Join(line, " "), code)
+ }
+ if !strings.Contains(printed.String(), "codeaf "+fakeCarried) {
+ t.Fatalf("`codeaf %s %s` printed no help:\n%s", fakeCarried, strings.Join(line, " "), printed)
+ }
+ }
+}
+
+// A TYPO OF A PROGRAM'S NAME IS ANSWERED WITH THE PROGRAM, like a typo of any
+// verb of codeaf's own.
+func TestAMisspelledProgramNameIsAnsweredWithIt(t *testing.T) {
+ carryFake(t)
+ if said := unknownCommand("fake-carrid").Error(); !strings.Contains(said, "codeaf "+fakeCarried) {
+ t.Fatalf("a typo of a carried program was answered %q", said)
+ }
+}
+
+// NO PROGRAM MAY SHADOW A WORD OF CODEAF'S OWN. The dispatch asks the build's
+// list last, after every verb, alias and hidden door it answers itself, so a
+// program named after one of them would be a verb nobody could ever reach —
+// and it fails the build here instead.
+func TestNoCarriedProgramShadowsAWordOfCodeafsOwn(t *testing.T) {
+ own := codeafsOwnWords(t)
+ for _, word := range []string{"do", "doctor", "help", "--version", "engine", "plandb"} {
+ if !own[word] {
+ t.Fatalf("%q was not read as a word of codeaf's own; the reader of main.go has stopped working", word)
+ }
+ }
+ for _, program := range append(builtin.All(), fakeCarriedProgram()) {
+ if own[program.Name] {
+ t.Errorf("the program %q shadows `codeaf %s`, a word codeaf answers itself; rename the program", program.Name, program.Name)
+ }
+ }
+}
+
+// codeafsOwnWords is every word the dispatch answers before it asks the
+// build's list: every case of run()'s switch — the hidden doors and the flag
+// spellings included — and every word the typo suggester offers.
+func codeafsOwnWords(t *testing.T) map[string]bool {
+ t.Helper()
+ file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
+ if err != nil {
+ t.Fatalf("parse main.go: %v", err)
+ }
+ words := map[string]bool{}
+ for _, decl := range file.Decls {
+ function, ok := decl.(*ast.FuncDecl)
+ if !ok || function.Name.Name != "run" || function.Recv != nil {
+ continue
+ }
+ ast.Inspect(function, func(node ast.Node) bool {
+ clause, ok := node.(*ast.CaseClause)
+ if !ok {
+ return true
+ }
+ for _, expression := range clause.List {
+ if literal, ok := expression.(*ast.BasicLit); ok && literal.Kind == token.STRING {
+ if word, err := strconv.Unquote(literal.Value); err == nil {
+ words[word] = true
+ }
+ }
+ }
+ return true
+ })
+ }
+ if len(words) < 20 {
+ t.Fatalf("only %d words were read out of run()'s dispatch", len(words))
+ }
+ for _, word := range knownCommands {
+ words[word] = true
+ }
+ return words
+}
+
+// A SHELL RUN'S CHILD IS HANDED THE PERSON'S OWN LINE: the command they named,
+// its own flags and the brief as they typed it, with --json added — and the
+// child's parser reads that line back to the same invocation.
+func TestAShellRunHandsItsChildThePersonsOwnLine(t *testing.T) {
+ program := fakeCarriedProgram()
+ for _, row := range []struct {
+ line []string
+ child []string
+ }{
+ {[]string{"--calls", "2", "fix", "it"}, []string{fakeCarried, "--json", "--calls", "2", "fix", "it"}},
+ {[]string{"run", "--wait", "--", "--not-a-flag"}, []string{fakeCarried, "run", "--json", "--wait", "--", "--not-a-flag"}},
+ {[]string{"check"}, []string{fakeCarried, "check", "--json"}},
+ } {
+ inv, err := delegate.Parse(program, row.line, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ child := carriedChildLine(inv)
+ if strings.Join(child, " ") != strings.Join(row.child, " ") {
+ t.Fatalf("%q became the child line %q, want %q", row.line, child, row.child)
+ }
+ again, err := delegate.Parse(program, child[1:], &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Command.Name != inv.Command.Name || again.Brief() != inv.Brief() || again.Workspace != inv.Workspace || !again.JSON {
+ t.Fatalf("the child reads %+v where the host read %+v", again, inv)
+ }
+ }
+}
+
+// A PREFIXED ID IS AN ACCOUNT, NOT PART OF THE MODEL: a shell run's adapter for
+// `openrouter/deepseek/…` puts the router's own id on the wire, and a
+// connection's own prefix is that connection's.
+func TestAShellRunsAdapterPutsTheServicesOwnIDOnTheWire(t *testing.T) {
+ router := modelsource.DefaultSource(config.DefaultBaseURL)
+ proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"}
+ settings := config.Config{
+ APIKey: "sk-or-v1-routerkey0000000000", BaseURL: config.DefaultBaseURL,
+ Sources: modelsource.NewSet(
+ modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: config.DefaultBaseURL},
+ modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address},
+ ),
+ }
+ adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}}
+ for model, wire := range map[string]string{
+ "openrouter/deepseek/deepseek-v4-flash-0731": "deepseek/deepseek-v4-flash-0731",
+ "deepseek/deepseek-v4-flash-0731": "deepseek/deepseek-v4-flash-0731",
+ "mybox/qwen3-coder": "qwen3-coder",
+ } {
+ built, ok := adapters.forModel(model).(wireCompleter)
+ if !ok || built.wire != wire {
+ t.Fatalf("the adapter for %q puts %q on the wire, want %q", model, built.wire, wire)
+ }
+ }
+ if first, again := adapters.forModel("mybox/qwen3-coder"), adapters.forModel("mybox/qwen3-coder"); first != again {
+ t.Fatal("an adapter was built twice for one model")
+ }
+}
+
+// A PROGRAM THIS BUILD CARRIES HAS ITS PAGE IN THE CHAT'S MANUAL, and the page
+// names both of its doors. The chat can say only what a page says, and a verb
+// the manual does not know is one the chat will improvise about or deny.
+func TestEveryCarriedProgramHasItsPageInTheChatManual(t *testing.T) {
+ for _, program := range builtin.All() {
+ page, ok := manual.Chat().Page(program.Page)
+ if !ok {
+ t.Errorf("%s names the manual page %q and the chat's manual has no such page", program.Name, program.Page)
+ continue
+ }
+ shell := regexp.MustCompile(`\bcodeaf ` + regexp.QuoteMeta(program.Name) + `\b`)
+ if !shell.MatchString(page) || !strings.Contains(page, "/"+program.Name) {
+ t.Errorf("%s's page %q does not name `codeaf %s` and `/%s`", program.Name, program.Page, program.Name, program.Name)
+ }
+ }
+}
diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go
index ff77b5ba5f..19a1acd559 100644
--- a/cmd/codeaf/chatv3.go
+++ b/cmd/codeaf/chatv3.go
@@ -1070,6 +1070,9 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
SubharnessMemory: subharnesses.Memory,
SubharnessLastRun: subharnesses.LastRun,
SubharnessRecordRun: subharnesses.Record,
+ // AND THE PROGRAMS THIS BUILD CARRIES that a task can be handed to
+ // whole (chatv3_delegate.go). Empty is none, on the terms above.
+ Delegates: v3Delegates(),
// The hand that paints, and the model it asks (internal/session's
// tools_image.go). The pair is CONDITIONAL on the other side — a nil
// client leaves generate_image off the belt entirely — so this is
diff --git a/cmd/codeaf/chatv3_delegate.go b/cmd/codeaf/chatv3_delegate.go
new file mode 100644
index 0000000000..f61981aa0e
--- /dev/null
+++ b/cmd/codeaf/chatv3_delegate.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
+)
+
+// THE PROGRAMS ONE CONVERSATION CAN HAND A WHOLE TASK TO: the ones this build
+// carries (internal/delegate/builtin). The list is read here, at the door, and
+// handed to internal/session, so the session package never imports a
+// program's whole engine and a test of it never carries one.
+func v3Delegates() []delegate.Delegate {
+ return builtin.All()
+}
diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go
index f89d924060..152a66626a 100644
--- a/cmd/codeaf/chatv3_host_test.go
+++ b/cmd/codeaf/chatv3_host_test.go
@@ -138,7 +138,15 @@ func TestH9HostMissingCommandNamesTheCurrentInstallation(t *testing.T) {
}
func TestSSHSpawnCarriesTheLowLatencyPolicy(t *testing.T) {
- t.Setenv("CODEAF_HOME", filepath.Join(os.TempDir(), "acp"))
+ // A SHORT HOME, because the control socket must fit enginehost.SocketLimit:
+ // under macOS's own $TMPDIR the path came to 104 bytes, one over, and the
+ // multiplexing options were rightly left out.
+ short, err := os.MkdirTemp("/tmp", "acp")
+ if err != nil {
+ t.Skipf("no short folder for the control socket: %v", err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(short) })
+ t.Setenv("CODEAF_HOME", short)
t.Setenv("CODEAF_PROFILE_DIR", t.TempDir())
args := strings.Join(sshTransportArgs("devbox", "codeaf engine"), " ")
for _, want := range []string{
diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go
index 404a97b892..3f152882b9 100644
--- a/cmd/codeaf/chatv3_local.go
+++ b/cmd/codeaf/chatv3_local.go
@@ -322,6 +322,13 @@ func openChatV3Local(launch localLaunch) error {
// road and not a use of this client's connection, and this is the door that
// knows the road (chatv3_taskowner.go says what makes it safe).
options.OpenTaskOwner = localTaskOwnerDoor(welcome.Workspace)
+ // AND IT CAN SEE THEM AT ALL. The rows that door sits behind are minted from
+ // the other conversations' presence files, which the agent this surface
+ // holds — a connection — cannot read; the engine is on this machine, so the
+ // files are on this disk beside the transcript the surface was handed, and
+ // the reading is taken straight off it. Without this the door above was
+ // never reached from a real window.
+ options.Elsewhere = session.ElsewhereOf
// AND HOME CAN TELL AN ENGINE FROM A WINDOW. It is bound on THIS road and no
// other, which is the absence law rather than an oversight: --host has its
// holder on this laptop and its journal on the far machine, and the in-process
diff --git a/cmd/codeaf/chatv3_taskowner.go b/cmd/codeaf/chatv3_taskowner.go
index 4210acf2af..7969ad7392 100644
--- a/cmd/codeaf/chatv3_taskowner.go
+++ b/cmd/codeaf/chatv3_taskowner.go
@@ -108,7 +108,14 @@ func openTaskOwnerView(workspace string, ask tui3.TaskOwnerAsk) (tui3.TaskOwnerV
// not — the page draws the question and the window that owns the work
// answers it (internal/remote's driver.go).
Questions: agent.WatchQuestions,
- Close: client.Close,
+ // AND ONE TASK'S STORED PAGE, which is the whole of what a program's task
+ // has to read: senior-dev writes no worker journal, and its actions are on
+ // its page in the owner's store. It is a read in the same sense —
+ // [remote.MethodPlanTaskPage] is on the watcher's allow-list and none of
+ // the page's verbs are — and it keeps the engine's refusal, which is how a
+ // program's page learns that the conversation under it was replaced.
+ TaskPage: agent.ReadPlanTaskPage,
+ Close: client.Close,
}, nil
}
diff --git a/cmd/codeaf/logs_test.go b/cmd/codeaf/logs_test.go
index e1b1e4835b..39dcc7f08d 100644
--- a/cmd/codeaf/logs_test.go
+++ b/cmd/codeaf/logs_test.go
@@ -30,6 +30,13 @@ import (
// they stopped at a live provider instead (testenv_test.go carries the whole
// case).
func TestMain(m *testing.M) {
+ // A SHELL RUN'S CHILD COMES IN HERE: carried_test.go starts this very test
+ // binary as the program's process, marked in its environment, and the
+ // binary then runs the dispatch the way `codeaf ` would. Its
+ // environment is the parent's, already isolated below.
+ if code, child := runAsCarriedChild(); child {
+ os.Exit(code)
+ }
if _, pinned := os.LookupEnv(calllog.EnvVar); !pinned {
os.Setenv(calllog.EnvVar, calllog.OffValue)
}
diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go
index cb7154e89d..de1c8800a2 100644
--- a/cmd/codeaf/main.go
+++ b/cmd/codeaf/main.go
@@ -28,6 +28,7 @@ import (
"github.com/Agent-Field/codeaf/internal/calllog"
"github.com/Agent-Field/codeaf/internal/codexauth"
"github.com/Agent-Field/codeaf/internal/config"
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
lanes "github.com/Agent-Field/codeaf/internal/lane"
@@ -393,6 +394,12 @@ func run() error {
case "-h", "--help", "help":
return usage(os.Args[2:])
default:
+ // A PROGRAM THIS BUILD CARRIES IS A VERB OF ITS OWN: `codeaf senior-dev
+ // ` (carried.go). It is asked last, after every verb above, so
+ // no program's name can shadow one of codeaf's own words.
+ if program, ok := builtin.Find(os.Args[1]); ok {
+ return runCarried(program, os.Args[2:])
+ }
return unknownCommand(os.Args[1])
}
}
@@ -705,7 +712,10 @@ func usage(args []string) error {
fmt.Fprintln(usageOut, environmentText)
return nil
}
- fmt.Fprintln(usageOut, usageText)
+ // The table with the programs this build carries in it (carried.go): a
+ // verb nobody can find on the page that lists the verbs is a verb nobody
+ // types.
+ fmt.Fprintln(usageOut, frontPage())
return nil
}
diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go
index f368cbabb2..3178d73033 100644
--- a/cmd/codeaf/usage.go
+++ b/cmd/codeaf/usage.go
@@ -9,6 +9,8 @@ import (
"strings"
"github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/delegate/builtin"
)
// This file is the ONE SEAM every subcommand's flags are built and parsed at.
@@ -474,7 +476,7 @@ func nearestCommand(typed string) string {
// not what anybody meant: `quux` is three edits from `run`, and answering
// with it would send somebody confidently to the wrong command.
best, distance := "", 3
- for _, candidate := range knownCommands {
+ for _, candidate := range commandWords() {
if measured := editDistance(typed, candidate); measured < distance {
best, distance = candidate, measured
}
@@ -482,9 +484,28 @@ func nearestCommand(typed string) string {
return best
}
+// commandWords is every word the dispatch answers to: codeaf's own
+// ([knownCommands]) and then the name of every program this build carries,
+// which is a verb of its own (carried.go) and a typo of which deserves the same
+// answer as a typo of `logs`.
+//
+// THE PROGRAMS ARE READ FROM THE BUILD'S LIST AT THE MOMENT OF ASKING, NOT
+// WRITTEN INTO THE LITERAL BELOW. The literal is codeaf's own vocabulary and is
+// read as source by internal/manual's terminal-verb gate; a program is on the
+// list only in a build that carries it — none on Windows — so its name belongs
+// to the list, and a literal naming it would be a verb this build may not have.
+func commandWords() []string {
+ words := append([]string(nil), knownCommands...)
+ for _, program := range builtin.All() {
+ words = append(words, program.Name)
+ }
+ return words
+}
+
// knownCommands is every word the dispatch answers to, in the order the table
// introduces them. `engine` and `tick` are deliberately absent for the same
-// reason they are absent from the usage text: nothing types them.
+// reason they are absent from the usage text: nothing types them. The programs
+// this build carries are joined to it where it is read ([commandWords]).
var knownCommands = []string{
"chat", "resume", "serve", "devices", "do", "plan", "revise", "run", "exec",
"show", "models", "pool", "notebook", "collections", "competence", "services", "wake", "patch",
diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md
index a0d1e1a665..17bc05bd17 100644
--- a/docs/TELEMETRY.md
+++ b/docs/TELEMETRY.md
@@ -17,15 +17,8 @@ codeaf sends anonymous usage counts to AgentField.
Turn off: CODEAF_TELEMETRY=off
```
-The installer prints a three-line form of the same notice, to stderr, after the
-`installed codeaf …` receipt and before the `export PATH` line. The full notice
-above still arrives at the first session:
-
-```
-codeaf shares anonymous performance data with AgentField
-codeaf does NOT share your prompts, code, files, or any private information
-see what is shared: codeaf telemetry info · turn off: CODEAF_TELEMETRY=off
-```
+The installer prints nothing about telemetry; the notice above arrives with the
+first session, before anything is sent.
## What is sent
diff --git a/docs/benchmarks/deepswe/README.md b/docs/benchmarks/deepswe/README.md
new file mode 100644
index 0000000000..9e5fed668c
--- /dev/null
+++ b/docs/benchmarks/deepswe/README.md
@@ -0,0 +1,61 @@
+# DeepSWE harness comparison
+
+Ten coding harnesses, one model, the same 113 tasks, one attempt each.
+
+| harness | solved | cost per task | cost per solved issue | mean time |
+| --- | --- | --- | --- | --- |
+| **senior-dev** | **62 of 113, 54.9%** | **22¢** | **1x** | 54 min |
+| mini-swe-agent | 56, 49.6% | 38¢ | 1.9x | 44 min |
+| codex | 51, 45.1% | 37¢ | 2.1x | 46 min |
+| pi | 42, 37.2% | 35¢ | 2.4x | 52 min |
+| omp | 31, 27.4% | 50¢ | 4.5x | 49 min |
+| opencode | 30, 26.6% | 50¢ | 4.8x | 48 min |
+| kilo | 30, 26.6% | 48¢ | 4.6x | 54 min |
+| claude-code | 16, 14.2% | 19¢ | 3.4x | 32 min |
+| deepseek-harness | 16, 14.2% | 150¢ | 26.6x | 94 min |
+| muse-code | 3, 2.7% | 12¢ | 11.3x | 16 min |
+
+senior-dev solved the most issues and paid the least for each one it solved:
+nearly 4x the issues claude-code solved, at about half the cost per solve of the
+next best harness.
+
+Since then, on the same 113 tasks: 88 solved (77.9%, exact 95% CI 69.1% to 85.1%)
+with DeepSeek V4.1 Flash, and 78 (69.0%, 59.6% to 77.4%) with Kimi K3. Those runs
+are senior-dev alone, not a comparison.
+
+## Setup
+
+| | |
+| --- | --- |
+| Benchmark | full DeepSWE set, 113 tasks, one seed per harness |
+| Model | `deepseek/deepseek-v4-flash-0731` through OpenRouter |
+| Verifiers | official DeepSWE at `0b9fabb` |
+| Budget | 3 h per task |
+| Isolation | four shards per harness, a dedicated OpenRouter key per harness |
+| Ran | nine harnesses on 2026-09-11; senior-dev on 2026-09-12 |
+
+[`arms.csv`](arms.csv) has one row per harness: solved, reward rate, valid
+grades, invalid outcomes, mean F2P and P2P, OpenRouter spend, cost per task,
+mean agent seconds and model.
+
+## How the columns in the README are derived
+
+- **solved**: tasks the verifier passed, out of 113.
+- **cost per task**: billed OpenRouter spend divided by 113, so tasks without a
+ verifier result stay in the denominator.
+- **cost per solved issue**: spend divided by tasks solved, shown as a multiple
+ of senior-dev's (39.9¢).
+- **mean time**: mean agent wall time per task.
+
+## Limits
+
+- One seed per harness. senior-dev's 62 against mini-swe-agent's 56 is not a
+ statistically resolved difference.
+- senior-dev departs from the sampling contract: the other nine sent temperature
+ 1.0 and top-p 0.95, senior-dev sent neither, so provider defaults applied.
+- Five tasks produced no verifier outcome: codex 1, pi 1, omp 2, opencode 1.
+ They count as unsolved.
+- omp, opencode, kilo, deepseek-harness, claude-code and muse-code did not record
+ per-attempt cost; their spend is known at harness level only.
+- senior-dev ran under an earlier name for the binary; values are rewritten to
+ `senior-dev`.
diff --git a/docs/benchmarks/deepswe/arms.csv b/docs/benchmarks/deepswe/arms.csv
new file mode 100644
index 0000000000..ba8c10e980
--- /dev/null
+++ b/docs/benchmarks/deepswe/arms.csv
@@ -0,0 +1,11 @@
+harness,solved,tasks,reward_rate,valid_grades,invalid,mean_f2p,mean_p2p,openrouter_spend_usd,cost_per_task_usd,mean_agent_seconds,model
+senior-dev,62,113,0.5487,113,0,0.8864,0.9942,24.73,0.2188,3258,openrouter/deepseek/deepseek-v4-flash-0731
+mini-swe-agent,56,113,0.4956,113,0,0.8817,0.9966,43.09,0.3813,2651,openrouter/deepseek/deepseek-v4-flash-0731
+codex,51,113,0.4513,112,1,0.8681,0.9876,42.23,0.3737,2746,deepseek/deepseek-v4-flash-0731
+pi,42,113,0.3717,112,1,0.7163,0.8909,39.61,0.3505,3101,openrouter/deepseek/deepseek-v4-flash-0731
+omp,31,113,0.2743,111,2,0.7191,0.8526,55.98,0.4954,2920,openrouter/deepseek/deepseek-v4-flash-0731
+opencode,30,113,0.2655,112,1,0.7113,0.8679,56.96,0.5041,2871,openrouter/deepseek/deepseek-v4-flash-0731
+kilo,30,113,0.2655,113,0,0.6903,0.9051,54.65,0.4836,3215,openrouter/deepseek/deepseek-v4-flash-0731
+deepseek-harness,16,113,0.1416,113,0,0.5339,0.9145,169.47,1.4997,5613,openrouter/deepseek/deepseek-v4-flash-0731
+claude-code,16,113,0.1416,113,0,0.3972,0.916,21.72,0.1922,1909,deepseek/deepseek-v4-flash-0731
+muse-code,3,113,0.0265,113,0,0.0514,0.9901,13.54,0.1198,952,deepseek/deepseek-v4-flash-0731
diff --git a/docs/changes/unreleased/1488-installer-no-telemetry-notice.md b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md
new file mode 100644
index 0000000000..af4d6d5336
--- /dev/null
+++ b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md
@@ -0,0 +1,10 @@
+---
+kind: removed
+title: the installer says nothing about telemetry
+pr: 1488
+surface: [build, docs]
+invalidates:
+ - "The installer printed a three-line telemetry notice after the `installed codeaf` receipt. It prints none now; the binary's full notice still arrives before the first session's events are sent."
+ - "When the install marker could not be written, the installer printed a line naming its telemetry folder. The marker is still written when it can be, and a failure is now silent."
+ - "docs/TELEMETRY.md carried a second fenced block, the installer's three-line form, and test/installer-telemetry.sh compared the installer against it. That block is gone and the test asserts the installer prints no notice."
+---
diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md
new file mode 100644
index 0000000000..0f6e829f52
--- /dev/null
+++ b/docs/changes/unreleased/1488-senior-dev.md
@@ -0,0 +1,38 @@
+---
+kind: added
+title: senior-dev, a coding agent built into codeaf, takes a whole task in the folder itself
+pr: 1488
+surface: [chat, engine, docs]
+invalidates:
+ - "There was no way to hand one large task to an agent of its own; the nearest thing was `bash` with `background: true`, which gave a job log and none of a task's limits, rail row or ending. senior-dev is now built into codeaf: `/senior-dev ` starts a run it does alone in the folder itself — on a branch of its own when the folder is a git repository, in place when it is not. `propose_task` takes `via: \"senior-dev\"`, and the model is told each program in the program's own words (its guide) plus codeaf's folder rule: hand it the folder the work belongs in as `ground`, clone a repository this machine lacks into a new folder first, and never brief it to work anywhere else. Nothing is installed. On Windows it is absent."
+ - "senior-dev was a separate program (swe-pro-go; called swe-pro until 2026-09-22) that read `OPENROUTER_API_KEY` itself. It is now part of codeaf, copied from swe-pro-go at 6103488 (local tag `codeaf-absorb`), and it runs only through codeaf: every model call it makes goes to a model API codeaf serves that one run. No key reaches it or any command its model runs. Each call is priced once into the conversation, the task and the spending ledger, and its ledger row names the conversation and the task, so `/cost`'s `tasks` line and the spend place show what a run cost. A call cut short by a stop or the ceiling is priced by its receipt, and the run is not over until that receipt is in (at most 70 seconds from when it was owed). The dollar ceiling refuses the call that would cross it (`the run's dollar ceiling of $… is reached ($… spent), so codeaf made no call`), and a run handed an already-spent ceiling makes no call at all. On a service that reports no prices (a local proxy, a sign-in) the dollar ceiling cannot hold; the manual says so and names `--max-hours` as the bound there. A model the person's services cannot serve is answered on the run's own work model, and the page names the model that answered."
+ - "A program's task page was a step list with no dollars until the run landed, and its stage was drawn nowhere. senior-dev's task opens inside the conversation's own tab, as any task does, from its row, its card, a task link, the task strip, the home panel or the sessions place; `esc`, the conversation's tab and the `home` tab leave it, and the program has no tab of its own. It shows the actions senior-dev takes, each under the step of its own process it served — `BRIEF`, `SETUP`, `SPEC`, `EXPLORE`, `PIN`, `CHECKLIST`, `IMPLEMENT`, `SUBMIT`, `VERIFY`, `FINISH` — with how each came out (`passes`, `fails · exit 1`, `4 files · 5 of 5 ticked`), the build and test commands it runs itself after the hand-in, `compacted its memory`, `switched to ` with the router's reason, its nudges and last turn drawn quieter, and `◐ thinking · 12s` while a model call is out; model names appear nowhere else. `ctrl+y` turns the page to the raw calls to its model and back. A line pinned over it says the step, the spend against the ceiling, the calls and the time, and the rail row says the step (`explore`, `verify`) and the spend. senior-dev reports this through optional fields on its protocol records (a step's `tool`, `step` and `exit`; a stage's `data`; stages `compaction`, `model-switch` and `verification · running`), and codeaf keeps them in the task's `delegate-actions.jsonl`; its algorithm is unchanged. The box sends nothing: `senior-dev reads no messages — say it to main`."
+ - "A senior-dev run's time was read off different clocks on different surfaces and was recorded nowhere: the page counted from before the copy was made to whenever the store happened to end, the rail from when the window first saw the run. It is now one span everywhere — from the hand-off to the moment senior-dev's own process ended, rounded to the second — on the page, the rail, the room, the landed card, the note the chat is handed (`done · ran 22m 51s · …`), the chat's `tasks` tool and the project's task list. The instants are kept in the task's `delegate-program.json`, and a reopened conversation still shows them."
+ - "The chat's `tasks` tool could not see a senior-dev run (`No task \"3\" in this project`), and nothing outside its own conversation could. It now reads the run, says how long it took, and every run takes a row in the project's task list, so the `@` list, other conversations and other windows see it. Another window that opens the run's row on its tasks place (`enter read it as it runs`) gets the same actions page, read-only, with `ctrl+y` for the raw calls and no stop, where it used to get the `[senior-dev]` badge over an empty page; a window reading another conversation may now read that task's stored page (and none of its verbs) on its connection. And that row was never drawn on an ordinary launch: the tasks place read the other conversations' presence only from an in-process agent, so a window on the engine (bare `codeaf`) showed another conversation's running work as `enter go inside it` and could never reach `enter read it as it runs` for any task. The engine launch now reads that presence off this machine's disk, so the row says `another window` and opens the reading page."
+ - "A second hand-off in a conversation whose earlier run had been left open ran inside that run's records: the second senior-dev was handed the first one's brief, and its calls, ceiling, stop and end time were written into the first task's page. Every hand-off now has its own store, record folder and brief. A run codeaf closed or crashed under used to read `running` for ever with its clock climbing; it now reads `incomplete` with `codeaf closed while senior-dev was running`, its time stopped where it was last seen working, and nothing waiting on the person. Closing a hosted conversation's window only detaches, as before."
+ - "Reopening a conversation after codeaf had closed under a run could set its whole task list aside as corrupt (`run row … is in state \"interrupted\"`), losing every task in it. Such a row is now written as the moving row it was, and a list an earlier build wrote that way is read."
+ - "The card that summarises a run and a question asked of a run on its page were model calls that no book counted; they are now in the conversation's spend, `/cost` and the ledger, and a program's run buys no summary at all. The home card and project facts counted a run's dollars twice; they now count them once."
+ - "senior-dev's file tools wrote anywhere the model named, outside the folder it was given. Its `write`, `edit` and `apply_patch` now refuse any path outside that folder, links resolved; reads stay open, and the shell is not fenced."
+ - "A program was placed by the same ladder as codeaf's own tasks, and its `in place` rung answered with the conversation's folder before the proposal's `ground` was read: a chat opened in the home folder that made ~/Desktop/pong, named it as ground and said `in place` handed senior-dev the whole home folder, which it began to snapshot and died on 60 ms in (`open ~/.Trash: operation not permitted`). A program's folder is now its proposal's `ground`, or the conversation's folder when it names none (the repository's root inside one), and nothing else is read for it; a `ground` that does not exist yet is made when its parent does; the card says `where: , on a branch of its own` (or the folder), and the receipt the model is handed names it. A program is never handed the home folder or one above it: the hand-off is refused with `senior-dev works in one project's folder, and is your home folder; say which folder the work is in, as ground`, and `/senior-dev` typed there is refused the same way. On a folder with no git history, a folder or file senior-dev may not read is skipped instead of ending the run; it needs no Full Disk Access."
+ - "senior-dev handed a folder with no git history ended at once with `workspace is not a git repository`, from the chat and from a shell. senior-dev now uses git only if it is there: a plain folder, a repository with no commit, a `.git` with no `HEAD` or a machine with no git runs on its snapshot recorder, as `--in-place` would, with or without the flag, so the chat never meets an ending it has no flag to fix. codeaf still starts it with `--in-place` under a repository rooted at the home folder or above, where git is there and must not be written to: it works in the folder itself and commits nothing. Wherever it works, its notes (`.senior-dev/`, the whole model conversation among them) are moved into the task's record folder when it ends, unless they were there before it started."
+ - "senior-dev ran in a copy of the person's repository and its work was left as one squashed `task:` commit on a branch nothing merged. It now works in the person's folder itself: in a repository codeaf records the branch they are on, switches the checkout to a new branch `task/-` (the person's own branch never moves), and senior-dev works there; when the run ends — done, not finished, or stopped — what it left uncommitted is committed onto that branch and the branch is left checked out, so the work is in the person's folder (`its work is on the branch in , N files, and that branch is checked out there; your branch main is as it was: `git -C '' switch main` goes back to it, and `git -C '' merge ` from there brings the work in`). A run that changed nothing switches back and deletes its branch (`it changed nothing, so is back on your branch and its branch was deleted`). A checkout with changes not committed, or in the middle of a merge or rebase, is refused before its card: ` has changes that are not committed (a.go, b.go and 2 more); commit or stash them, then ask again`. One run works in a folder at a time, from any conversation, window or shell, and a folder inside or around a busy one is busy too. While it works, nothing else of codeaf's writes there: the chat's file tools refuse a path inside the folder, and a task grounded on it is refused (` is busy: senior-dev, task 1 (), is working in it, and nothing else of codeaf's works there until that run has ended; wait for it, or stop it, then ask again`); the shell is not fenced, and the person's own edits there join its work. A run whose process went away (codeaf closed, a crash, a killed shell run) is settled without touching git: its work stays on its branch as it left it, and the ending says how many files are not committed. codeaf reads the person's branch again before it says it is as it was, and switches branches with the repository's hooks off. The copy, the rewriting of folder paths in the brief, the squash and the landing are gone."
+ - "A program that ended without finishing drew `a fault: ran and did not finish`. The row now carries the program's own sentence (`senior-dev did not finish: submitted a change that the project's own build or tests do not pass`), is not drawn as a fault (new ending `program`), keeps that ending and its branch across a reopen, and the conversation is told the program's account; a program crash is still a fault."
+ - "senior-dev routed on a built-in list of six open models whatever the person's crew said. A run a conversation starts now hands it the crew's worker (`--high`) and low (`--low`) models; it skips a crew model its catalog cannot size and uses its own list only when none is left (`--crew`). The mastermind is not passed, because senior-dev has no call that would use it."
+ - "A model the person asked for was shown on the card and dropped: senior-dev was handed the crew's working seat whatever the proposal named. The models a proposal names (one, or several separated by commas) are now senior-dev's working pool (`--asked --high …`), named on the card and in the receipt; a model none of the connected services can serve is refused before the card, by name, where it used to be answered on the crew's seat call after call; a model spelled with a connected service's prefix (`mybox/qwen3`) is taken as written; and one senior-dev's catalog cannot size ends the run before its first call, naming it, where a crew seat it cannot size is dropped for its own list. A proposal naming no model is handed the crew, as before."
+ - "codeaf reached for senior-dev only when its model read a paragraph calling it for \"one large code change worth an hour\". The paragraph now says work a program is for goes to it whole, rather than to the conversation or its own worker, and senior-dev's guide claims complex, multi-part coding work in a real project: an issue in a mature codebase whose cause spans files, a feature with its tests, a rewrite across a package, a migration. A proposal that leaves out a program the person named (by name or as `/name`, in the message or a correction typed into the same turn) is turned back once — `the person named senior-dev: if they want it to do this work, propose this again with `via: \"senior-dev\"`; if they asked for it not to be used, or did not mean the program, propose it again unchanged` — every such proposal of that reply is turned back, and one the model makes after reading it passes. An ask for a program lifts the one-command floor (`fix this file with senior-dev` goes to senior-dev); a passing mention does not; a commit, undo or revert stays in the conversation whatever `via` says. The approval card and its countdown are unchanged."
+ - "Nothing distinguished a program's task from codeaf's own: the rail row, the card and the page drew them alike, and the card did not say where the work was going. A program's tasks now wear its name as a badge — `[senior-dev]`, bold in the accent colour, after the title — on the side list (`[sd]` on the narrow one, the `#id` going first and the title cut last), the card (`wants to start a [senior-dev] task: `), the task's page, the strip, the `@` list, the tasks place and home; the chat's `tasks` tool says `via senior-dev`. The badge is made from the program's name, so a program added later wears its own. An ordinary task wears none. The card's `from your folder as it stands — unsaved edits included` line is not on a program's card."
+ - "senior-dev's ending went nowhere codeaf acted on: it was a sentence on a row, read by the chat only if the person asked something next. Now every run's ending wakes the conversation with how it came out (passed its own check, unchecked, fails, stopped on a limit, broke) and what to do about it, under a playbook page of its own: the chat checks the work, fixes a small gap on the branch, or hands the work back to senior-dev with a sharper brief — at most twice on its own (a third is refused: `senior-dev has been sent back to this work 2 times already, the most codeaf does on its own: tell the person where the work stands and let them decide`), and never after a dollar or time limit without the person's word. The landed card says `ended` and `senior-dev's ending went to the chat` instead of the program's status."
+ - "senior-dev's page named its task three times — the trail's crumb (the conversation's name, which a conversation named after its work spells the same), the bold title, and `Reading: ` over the box — and opened on a clipped brief. The head is now the title row alone, with its badge, a `▸ brief` dropdown and the pinned facts; the dropdown (or `ctrl+o`) draws the whole brief in grey between the head's rules. Every action with more to show opens to its whole step — the command or file it was called with and what came back — on a click, and folds on another. The page's notes keep `senior-dev's ending went to the chat` and where the work is, not the program's status."
+ - "senior-dev's page said `wrote a.go` and `edited b.go` with nothing about how much each changed. Every change to the work now wears git's `+N,-M` at its right edge, the added lines in the diff's green and the removed in its red: counted by the file tools themselves (`write` now counts its lines as `edit` and `apply_patch` already did), carried on the protocol's `step` record as `added` and `removed`, and never on senior-dev's own spec, pinned check or checklist."
+ - "A second senior-dev run in a folder the last one left on its branch cut a new branch from it and called the first run's branch `your branch`; a second run that changed nothing deleted its own branch and switched back to the first's. It now carries on on the same branch (`carrying on on its branch task/x, where the last run left it; your branch main does not move`), its ending names the person's real branch, and nothing an earlier run committed is ever deleted."
+ - "A senior-dev run cannot wait and is never carried on, and nothing offered otherwise. A proposal handed to it whose `depends_on` names work that has not landed is refused before its card (`depends_on names task 3, which has not finished, and senior-dev starts the moment it is approved — it cannot wait. …`), where it used to start at once with its `depends_on` dropped; any task may name a senior-dev run that ended done, and one still going is refused in its own sentence. Its card offers no retry, the `@` block offers the stop and no steer (`senior-dev reads no messages; stop it with tasks id 7 stop`), and its row says `senior-dev's run is never carried on: its work is left where it ended, and a new hand-off starts a new run`. Its landed card, opened, and the chat's `tasks` tool say what the run cost."
+ - "Opening a task's room froze its side-list clock at the moment of the click, and the row kept drawing that stopped age (senior-dev's row read `2s` for over a minute beside a page reading `1m 21s`). The row now leaves its clock out while the room is open and reads the whole true age again when the person leaves."
+ - "`codeaf senior-dev [flags] ` runs it from a shell in the current folder (or `--dir`) by the same rules as the chat — its own branch in a repository, in place in a plain folder — with `--max-cost`, `--max-hours`, `--json` and its own `--variant`, `--high`, `--in-place`; `codeaf --help` lists it. Its last line says what the run came to (`277 model calls · $2.30 · 22m 51s`), it waits for a cut call's price before it prints it, and a second ctrl-c leaves at once. A shell run keeps its record under `~/.codeaf/v3/carried/senior-dev/`. There is no `codeaf delegate` and no `/delegate`: \"delegate\" names the idea in code only."
+ - "`SIZE-BUDGET` was 54,600,000. It is 57,400,000: the old figure plus what the engine weighs on the heaviest platform, tabled in PERF.md, which also names that darwin/amd64 and linux/amd64 were already over the old figure before this change."
+ - "The chat's prompt-size caps (internal/session's prefixbudget_test.go) were 56,146 bytes for the full prefix and 48,814 for the lean one on dev, and neither weighed the programs paragraph. Both are fixed, and the caps are 57,124 and 49,590: raised by exactly what the paragraph and the preference for a program cost (978 and 776 bytes), on the owner's calls of 2026-09-23 and 2026-09-24 (\"raise the cap only as much as necessary\")."
+ - "A draft that installed programs from manifests in `~/.codeaf/delegates` was built and never shipped; it is kept on the tag `delegate-manifest-v1` for when programs from outside the binary return."
+---
+
+`docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate`
+is its specification in Go. senior-dev needs its model catalog (models.dev, fetched once and
+cached); an offline machine with no cache refuses the run.
diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md
new file mode 100644
index 0000000000..d647a4e91b
--- /dev/null
+++ b/docs/design/delegate/DESIGN.md
@@ -0,0 +1,358 @@
+# Delegates — handing a task to an outside program — DESIGN (draft)
+
+> **Superseded in part on 2026-09-23.** The owner moved the first release to
+> programs BUILT INTO codeaf: no manifests, no `~/.codeaf/delegates`, no install,
+> no `/delegate`; senior-dev copied into `internal/seniordev` from swe-pro-go at
+> the tag `codeaf-absorb` (`6103488`); its CLI is `codeaf senior-dev`; every
+> model call goes through a per-run model API codeaf serves; and the task page
+> shows the program's conversation with codeaf (since 2026-09-24, the actions it
+> took, step by step, with the conversation one key away). The protocol is now internal,
+> version 2: [PROTOCOL.md](PROTOCOL.md). What follows is the v1 design as it was
+> built; the manifest road is kept on the tag `delegate-manifest-v1`. The run
+> road, the answer folded in for text, the stop and the reader below all carry
+> over.
+>
+> **Superseded again on 2026-09-24: a program works in the folder itself.** The
+> owner asked why it was so hard to have senior-dev just work on the problem,
+> and the copy per run, the brief's paths rewritten to name it, the squash and
+> the HEAD-restoring landing were all deleted. A program that edits files now
+> works in the folder the task names, on a branch codeaf cuts for it there when
+> the folder is a git repository, and when it ends codeaf commits what it left
+> onto that branch and leaves it checked out; the person's branch never moves.
+> The contract is the header of `internal/session/programfolder.go`, and
+> [PROTOCOL.md](PROTOCOL.md) §2 says it. Every "working copy", "squash" and
+> "merge home" below is the design as it was built before that day.
+
+
+*2026-09-21, revised 2026-09-23. Written against `dev @ 17ae56d34` and
+`swe-pro-go @ 6103488` (branch `zeropoint95/improvements`, PR #30). Waves 1 to
+4 are built on this branch; every senior-dev change this asked for has landed.*
+
+*The first delegate was called `swe-pro` when this was written. It was renamed
+`senior-dev` in its own repository on 2026-09-22 (`b43daaf`): the binary,
+`cmd/senior-dev`, the `.senior-dev/` run folder, `refs/senior-dev/*` and every
+`SENIOR_DEV_*` variable. The repository and Go module keep the name
+`swe-pro-go`. The commit pins below predate the rename and are still in its
+history.*
+
+## In one paragraph
+
+A **delegate** is an outside program that does a whole coding task on its own.
+You start one by typing its name as a command:
+
+```
+/senior-dev rewrite the auth middleware to use the new session store
+```
+
+That starts an ordinary **task**. It runs in the folder itself, on a branch of
+its own in a repository, under your dollar and time limits, shows on the rail,
+can be stopped, and leaves its branch checked out when it ends. The chat is not
+blocked while it runs, but nothing else of codeaf's writes in that folder until
+it ends (internal/session's programhold.go). Inside codeaf, a
+delegate is one more **worker kind** behind the existing run supervisor. It is
+not a second engine.
+
+`senior-dev` is the first delegate. Others are added later, one manifest each,
+at the person's discretion.
+
+## Decisions already taken
+
+| decision | answer | date |
+| --- | --- | --- |
+| Name | **delegate**, not sub-harness (that word is taken, see below) | 2026-09-21 |
+| Command | `/ `, one word per installed delegate | 2026-09-21 |
+| What it starts | a task through the existing `/task` door, never a blocking turn | 2026-09-21 |
+| Questions from the delegate | none. The brief must be self-sufficient | 2026-09-21 |
+| senior-dev's `wip(edit)` commits | squashed into one commit at landing (2026-09-21); kept on the program's own branch, under one commit of what it left uncommitted, from 2026-09-24 | 2026-09-24 |
+| senior-dev control plane | optional. Landed in senior-dev `f3b9716` | 2026-09-21 |
+| Live cost from senior-dev | a top-level `spend` record. Landed in senior-dev `5793499` | 2026-09-22 |
+| Steps from senior-dev | a `step` record per finished tool call. Landed in senior-dev `5793499` | 2026-09-22 |
+| Command rows and the manual law | rows are generated at launch; each delegate ships its own manual page; the law is checked at load | 2026-09-21 |
+| Readers | **one generic reader**, compiled in, over a small stdout protocol. No per-program reader | 2026-09-21 |
+| Delegates that produce no tree | allowed. The manifest says `"lands": "text"` and the terminal record's text is the deliverable | 2026-09-21 |
+| Stage records on the task page | stages feed the live step only; `step` records are the trajectory, so the step count is what the program said it did | 2026-09-22 |
+| The task page draws actions, not a dialogue | **superseded the row above, 2026-09-24.** Every stage, step and ending is also written to the task's action log (`delegate-actions.jsonl`) as it arrives, stamped with codeaf's clock; the page draws the program's actions under the steps of its own process through the program's own vocabulary (`Delegate.Present`), and the raw calls are one key away (`ctrl+y`). The live step names the step the program is in. Steps still feed the trajectory | 2026-09-24 |
+| Review round on a delegated run | none. A check seat is a bash-belt worker the belt switch may have left off; the program's own checking is in its result | 2026-09-22 |
+| The run road and the belt switch | a delegated run takes the run road whatever `CODEAF_TASK_BELT` says; only the worker kind differs | 2026-09-22 |
+| A delegate runs alone | nothing joins a delegated run and no delegate joins a run underway; both are refused naming the busy folder | 2026-09-22 |
+
+## Why not "sub-harness"
+
+The word already means something else in this repository:
+
+- `/harness` lists **saved shapes of work**: small programs built from this
+ binary's own node kinds (agent loop, tool call, verify, human gate). A
+ designer model builds one in conversation and saves it under
+ `~/.codeaf/harnesses//vN.json`.
+- `/subharness` lists those plus bundles on disk and built-ins, and opens an
+ intake card for one.
+
+Both run **inside this process**. A delegate is an **outside binary** codeaf
+cannot see into. One word for both would confuse the manual, and the manual
+is what the chat answers from.
+
+| command | what it starts | who wrote it | where it runs |
+| --- | --- | --- | --- |
+| `/harness` | a saved shape of work | codeaf, at your request | in this process |
+| `/subharness` | the same, through an intake card | codeaf or a bundle author | in this process |
+| `/senior-dev` | an outside program | someone else | a child process in a working copy |
+
+## The command
+
+`/senior-dev ` is `/task ` with the worker already chosen.
+
+1. The same card appears. You answer it before money moves.
+2. The turn ends. You are not held for the hour.
+3. A run starts in the tasks store, in its own working copy.
+4. It shows on the rail with a live step. `stop` works.
+5. When it ends, the landing wakes a turn, as every task does today. That
+ turn reads senior-dev's terminal record and the landing note, and answers.
+
+The model never watches the stream. You watch the rail.
+
+**Rows are generated.** A manifest at `~/.codeaf/delegates/.json` whose
+binary is on PATH adds one row `/ ` to the live command list, so
+`/help` and the picker show it beside `/task`. No senior-dev on the machine means
+no `/senior-dev` row. A name that collides with a built-in command or alias is
+refused, naming the row.
+
+**The model can propose one too.** `propose_task` gets an optional `via`
+field. The system prompt names installed delegates the same conditional way
+it names everything else (`HANDOFF_FACTS` in `beltfacts.go`), and says when
+to pick one: a change big enough to want its own agent for an hour, specified
+well enough that nobody will be asked anything.
+
+**`/delegate`** (bare) lists the delegates on this machine: the binary each
+resolved to, and the last run in two words. It answers "which do I have here".
+
+## Where it plugs in
+
+The run supervisor already gives every worker what a delegate needs. The
+worker contract is one method:
+
+```go
+// internal/run/worker.go
+type Worker interface {
+ Run(ctx context.Context, task plandb.Task) (Report, error)
+}
+type Report struct { Result string; Steps int; USD float64; Waiting bool }
+```
+
+| already exists | where |
+| --- | --- |
+| cost and time ceilings handed to the worker | `run.Limits` |
+| a spend bank the worker reports rising dollars into | `run.WithSpendBank` |
+| the live step the rail draws | `plandb.Store.SetLive` / `ClearLive` |
+| the trajectory the task page opens | `trajectory.jsonl` |
+| a working copy cut per run | `task_run_copy.go` |
+| landing and merge home | `run.Land`, `landBeltRun` |
+| stop, proven reachable for every running row | `session.Cancel`, `stoplaw_test.go` |
+| the run's dollars folded into the conversation total | `driveBeltRun`'s `foldSpend`, #1280 |
+
+**A delegate is a second `run.Worker`.** `CrewFactory` picks a worker per task
+by role today. It gains one branch: a task whose row names a delegate gets a
+`delegate.Worker` instead of a `BashWorker`. Nothing above the factory changes.
+
+**The floor.** With no change at all, the model can run `senior-dev run …`
+through the `bash` tool in the background. That gives a job log and an exit
+notice, and none of the rows in the table above. That gap is what this design
+pays for.
+
+## The contract a program must meet
+
+| the program must | senior-dev today |
+| --- | --- |
+| **launch** from argv with brief, directory, dollar ceiling, wall ceiling | `senior-dev run --dir D --max-cost X --max-hours H -- "goal"` |
+| **stream** progress as one JSON object per line on stdout, nothing else | yes, EVENTS-CONTRACT.md |
+| **end** with exactly one terminal record: status, reason, `cost_usd` | yes, `{"type":"terminal",…}` |
+| **stop** cleanly on SIGTERM, still writing the terminal record | yes. Only SIGKILL loses it |
+| **leave its work in the tree** it was given, and nothing else | yes. `.senior-dev/` is git-excluded |
+
+Two things codeaf does **not** ask, and the manual page says so:
+
+- **No questions.** senior-dev auto-rejects its own `question` tool and has no
+ stdin road. Write the brief so nobody needs to be asked.
+- **No step cap.** senior-dev has cost and hours only. The step count on the
+ task page is whatever the reader can count off the stream.
+
+### The manifest
+
+```jsonc
+// ~/.codeaf/delegates/senior-dev.json
+{
+ "name": "senior-dev", // also the command: /senior-dev
+ "description": "an autonomous coding agent for one large, well-specified change",
+ "bin": "senior-dev", // resolved on PATH; a path is allowed
+ "argv": ["run", "--dir", "{{workspace}}",
+ "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}",
+ "--", "{{brief}}"],
+ "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" },
+ "lands": "tree", // "tree": squash and merge the copy; "text": the terminal's text is the answer
+ "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false }
+}
+```
+
+- `{{key:openrouter}}` resolves through `config.APIKeyAt`, the same door every
+ lane uses, so a key pasted at first-run setup reaches the delegate (#576).
+- A `bin` not on PATH means the delegate is absent, not broken. `/delegate`
+ draws one dim line naming the binary it looked for.
+- A `manual.md` ships beside the manifest. See *The manual law* below.
+
+### The protocol, and the one reader
+
+There is **one reader**, compiled in. It reads a small protocol on the
+program's stdout: one JSON object per line, four record types, everything
+else ignored. Ignoring the rest is what makes it generic: senior-dev's bus
+payloads pass straight through it.
+
+| record | required fields | the reader makes it |
+| --- | --- | --- |
+| `{"type":"stage","stage":S,"status":T}` | `stage`, `status` | the live step, `S · T`, and one trajectory line |
+| `{"type":"spend","cost_usd":C}` | `cost_usd`, cumulative, non-decreasing | banked spend |
+| `{"type":"step","command":X,"observation":Y}` | `command`; `observation` optional | one trajectory step. `Steps` counts these. Optional: a program with no steps is drawn by its stages |
+| `{"type":"terminal","status":U,"message":M,"data":{"cost_usd":C,…}}` | `status`, `message`, `data.cost_usd` | the `Report` and the outcome. Exactly one, last |
+
+`terminal.status` is a closed set, and it is senior-dev's:
+
+| `status` | run outcome | rail word |
+| --- | --- | --- |
+| `pass` | done | done |
+| `fail` | ran and did not finish | incomplete |
+| `budget-exhausted` | a limit you set stopped it | stopped, naming the limit (#1279) |
+| `crashed` | ran and did not finish | incomplete |
+
+Process exit with no terminal seen is `ran and did not finish`, naming the
+last stage seen. Optional `data` keys the landing note reads when present:
+`reason`, `claim` (what the program's model said), `observed` (what the
+program itself saw), `deliverable` (the answer text, for `"lands": "text"`).
+
+**What this costs each program:**
+
+- **senior-dev** emits all four in exactly this shape as of `5793499`. Its
+ `step` is one per tool call reaching `completed` or `error`, never twice
+ for a republished part; `command` is `tool: argument`, the argument capped
+ at 200 bytes; `observation` is the output or the error string, capped at
+ 2048 bytes on a rune boundary. Nothing to adapt.
+- **pr-af** needs a one-shot mode that prints these four records and exits:
+ `stage` per review phase, `spend` per model call, `terminal` with the
+ findings as `data.deliverable`, and `"lands": "text"` in its manifest.
+
+**Never sum `cost` off senior-dev's `message.updated`.** An assistant message is
+written more than once, so a naive sum double-counts. The `spend` record
+exists for exactly this reason.
+
+senior-dev keeps the model's claim and its own observation as separate fields.
+The landing note keeps them separate too: *senior-dev says it submitted; its
+verification failed 2 of 5 commands* is two sentences.
+
+### Two kinds of landing
+
+| `lands` | working copy | when the program ends |
+| --- | --- | --- |
+| `tree` (senior-dev) | cut per run, passed as `{{workspace}}` | squash, merge home, landing card |
+| `text` (pr-af) | none; `{{workspace}}` is the person's folder, read-only by contract | `data.deliverable` is folded into the conversation the way a quick task's answer is, and the woken turn reads it |
+
+### Money
+
+1. senior-dev spends the person's key outside codeaf's provider ledger.
+2. The reader hands every rising `spend` figure to the supervisor's bank.
+3. At the ceiling the supervisor cancels the context, which sends SIGTERM,
+ which lets senior-dev write its terminal record.
+4. The conversation total, `/cost` and the status line move through
+ `foldSpend`, as for any run.
+5. The on-disk usage ledger does **not** get senior-dev's calls, because they
+ did not go through a codeaf lane. The spending page says `via senior-dev`.
+
+The ceiling passed on the command line is what is left of the smaller of the
+conversation's limits (`runCostLeft`, #1281), so senior-dev cuts itself first.
+
+### Stopping
+
+`stop` on the row is `session.Cancel` with a new kind, `delegate`, listed in
+`stoplaw_test.go` with its proving test. The worker terminates the process
+group, waits the job grace, then kills. A terminal record inside the grace is
+read and folded. Without one the row reads `stopped` with the last stage seen.
+
+### Landing a `tree` delegate
+
+*As built on 2026-09-21 and deleted on 2026-09-24: since then senior-dev works in
+the person's folder on a branch of its own, its `wip(edit)` commits stay on that
+branch, what it left uncommitted is committed there when it ends, and the branch
+is left checked out rather than merged. `refs/senior-dev/*` are written into the
+person's repository and overwritten by the next run.*
+
+1. senior-dev works in the run's own copy, passed as `--dir`.
+2. senior-dev commits every edit as it goes: `wip(edit): `, dozens per run.
+ These stay on inside the copy, because senior-dev's crash recovery and its
+ restore-after-ship read them.
+3. At landing codeaf **squashes** everything past the cut point into one
+ commit. Subject: the task's title. Body: two sentences from the terminal
+ record, what the model claimed and what senior-dev observed.
+4. That one commit merges home the way every task lands.
+5. `.senior-dev/` is git-excluded in the copy and never lands. `refs/senior-dev/*`
+ die with the copy.
+
+## The manual law
+
+Delegates are added at a person's discretion, so `/` rows cannot be a
+build-time list, and a page compiled into every binary cannot explain them.
+The law stays: every command the chat offers is explained in the corpus. Where
+it is enforced moves.
+
+1. **The static table keeps its static gate.** `internal/tui3`'s `commands`
+ and its test are unchanged. Delegate rows are appended to the live list at
+ launch and never enter the Go literal.
+2. **Each delegate ships `manual.md`** beside its manifest, following the same
+ rules as `internal/manual/chat/` pages. At launch the chat's corpus is the
+ packed corpus plus an **overlay** of installed delegate pages. `manual.Corpus`
+ gains one constructor that layers pages over another corpus. The `manual`
+ tool then answers "what does /senior-dev do" from senior-dev's own page.
+3. **The check runs at load.** A page that does not mention `/` refuses
+ the manifest. `/delegate` shows why: `senior-dev: its manual page does not say
+ /senior-dev — not added`.
+4. **One built-in page explains the family.** *Delegates — programs codeaf can
+ hand a task to* mentions `/delegate` and answers "what is a delegate", "how
+ do I add one", "why is there no /senior-dev here". It never names a delegate
+ the build cannot promise exists.
+
+## What senior-dev changed for this
+
+Landed 2026-09-21 and 2026-09-22 on `zeropoint95/improvements`, PR #30.
+
+1. **Control plane optional** (`f3b9716`). Reachable: mirrored as before.
+ Unreachable: one stderr line, and the run proceeds. The `run-contract`
+ record carries `"control_plane": {"enabled": false, "url": ""}`.
+ `senior-dev serve` still requires a plane. The manifest sets no `SENIOR_DEV_CP_*`
+ variable.
+2. **Live spend record** (`5793499`). `{"type":"spend","cost_usd":0.0213,"ts":…}`,
+ top-level, one per completed assistant message, cumulative, compaction
+ included. Emitted even at zero. Not projected onto the control plane.
+3. **Step record** (`5793499`). `{"type":"step","command":"bash: go test ./...","observation":"…","ts":…}`,
+ one per finished tool call. stdout only, not in the stderr trace.
+4. **No question road**, by decision. Auto-reject stays.
+
+Both stream additions were verified on the senior-dev side to touch only the
+event layer: nothing under its engine, session, prompt builders or tool-result
+path changed, and a standing test asserts the exact stdout record count.
+
+Checked by the senior-dev side against its code: the outcome table above holds,
+SIGTERM still writes the terminal record, and `--` before the goal parses.
+
+## Waves
+
+| # | lands | proof |
+| --- | --- | --- |
+| **1** ✓ | `internal/delegate`: manifest and loader; `Worker` (spawn under `processgroup`, stream to the reader, SIGTERM then kill, `Report`); the one generic reader and its protocol, already written down in `docs/DELEGATE-PROTOCOL.md` | unit tests against a fake binary emitting scripted protocol lines and honouring SIGTERM; the outcome table pinned; a recorded senior-dev stream replayed through the reader |
+| **2** ✓ | the door (`via` rides the run, not a store column: a delegated run is one task); `CrewFactory` branches on it; generated `/` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the `delegate` cancel kind; squash-then-merge landing for `tree`, text fold for `text`; `via` on the spend row | focused `internal/session` and `internal/tui3` tests |
+| **3** ✓ | the manual: the built-in *Delegates* page; the corpus overlay; the load-time page check; senior-dev's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to senior-dev", "what does /senior-dev do", "why can't the delegate ask me", "difference between /harness and /senior-dev" |
+| **4** ✓ | hosted: the door crosses the wire (`Delegate.List`, `Delegate.Start`, wire version 18), so a `--host` surface generates its rows from the far machine's registry and a delegate runs there | `internal/remote` surface-door law; `internal/tui3` delegate tests |
+| later | `codeaf do` speaking the protocol so codeaf on another machine is a delegate; pr-af's one-shot mode; delegates chosen by crew seat; answering a delegate's question | — |
+
+Wave 1 has no door and spends no money. Wave 2 is the first thing a person
+can type.
+
+## Open questions
+
+1. **Who picks senior-dev's models.** Today its own `--high` default. The manifest
+ could pass codeaf's work seat, but senior-dev speaks OpenRouter slugs and the
+ seat may be on another lane. First cut: the manifest's argv, no seat.
diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md
new file mode 100644
index 0000000000..f9af6ca270
--- /dev/null
+++ b/docs/design/delegate/PROTOCOL.md
@@ -0,0 +1,235 @@
+# The protocol, version 2 (internal)
+
+*2026-09-23. What a program codeaf carries does, and what codeaf does for it. It
+replaces the public, manifest-based v1 (`docs/DELEGATE-PROTOCOL.md`, kept on the
+tag `delegate-manifest-v1`). The owner's plan is the "Built-in delegates plan"
+doc; the Go types in `internal/delegate` are the specification, and this page
+says what they mean. "Delegate" is a working title: a person only ever reads the
+program's own name.*
+
+## 1. What a program is
+
+A value in the build's list, `internal/delegate/builtin`, of type
+`delegate.Delegate`: a name (the chat command `/` and the shell verb
+`codeaf `), a one-line summary, a guide, what it lands (`tree` or `text`),
+its commands with their own flags, its default command, the flags that command
+takes to work in a folder with no git history (`PlainFolder`), and the name of
+its page in the chat's manual. There is nothing to install. A program not in the list does
+not exist anywhere; on Windows the list is empty.
+
+**The guide is the program describing itself to the model that hands it work:**
+one paragraph of at most 400 bytes (`delegate.GuideMax`) saying what it is for,
+and what its brief must hold. The conversation prints
+it under the program's name, beside `propose_task`'s `via`, and says nothing about
+the program of its own. It rides every request of every turn, which is why it is
+short and why the manual page carries the rest.
+
+**The program owns what is true of it; codeaf owns what is true of every
+program.** That a program that edits files works in the task's folder itself, on
+a branch of its own in a repository, and so the rule that it must be handed the
+repository the work belongs in (cloned first when the machine lacks it, and never
+briefed to work anywhere else) are codeaf's to say, once, beside the list; the
+rule is printed only when a program that lands a tree is carried. That nobody can be asked
+anything is `propose_task`'s own. A guide repeats none of it.
+
+A program cannot run on its own. Its entry point is a `Command` whose body takes
+a `delegate.Host`, and only codeaf makes one.
+
+## 2. How it runs
+
+Always as a child process of codeaf's own executable:
+
+```
+codeaf --json --dir [--max-cost USD] [--max-hours H] [plain-folder flags] --
+```
+
+**The folder is codeaf's to read, and the program is told what it found.** A
+program that edits files works in the folder itself, never a copy
+(`internal/session`'s `programfolder.go`, whose header is the contract): the
+folder the proposal names, or the conversation's, or the shell's, snapped to its
+repository's root. In a repository with a commit whose root is below the home
+folder, codeaf writes the person's branch down, refuses a checkout with changes
+that are not committed or a merge half done, and cuts the program's own branch
+with `git switch -c task/-`; the program works there in its own git
+mode. Anything else — no history, no commit, or a repository at the home folder —
+is worked in as it is, and codeaf puts the program's own `PlainFolder` flags on
+its line (senior-dev's is `--in-place`), because the program's own reading climbs
+to any repository around the folder. senior-dev reads its folder too, and uses
+git only where there is a work tree with a commit, so the flag is needed only
+where git IS there and must not be used (the home folder's repository) — a
+plain folder never ends it, whatever its line says. codeaf never learns a program's flag by
+name, and a flag the default command does not take fails `Validate`, so the
+build's own test catches it. One folder takes one program run at a time, held by
+a file lock that dies with its process.
+
+**The brief is handed over as written.** There is no copy for a path in it to be
+rewritten into.
+
+**A tree program's work stays on its branch, checked out.** When the run ends,
+however it ends, codeaf commits what the program left uncommitted onto its branch
+(the task's title, the ending as the body), moves the program's notes out of the
+folder, and leaves the branch checked out; the person's branch never moves and
+nothing is merged into it. The page says `its work is on the branch in
+, N files, and that branch is checked out there; your branch is as
+it was: …` with the commands that go back and bring the work in. A run that
+changed nothing switches back and deletes its empty branch; a HEAD the program's
+shell moved off its branch is left where it is and said.
+
+**The crew rides the line.** A run a conversation starts carries its crew
+(`delegate.Crew`: brain, hands, light — the mastermind, worker and low tiers,
+effort taken off) in the program's own flags (`Delegate.CrewFlags`); a program
+with none picks its own models. senior-dev's are `--crew --high
+--frontier --low `, and `--crew` makes it drop a model its
+catalog cannot size, and route on its own list if the working seat is left
+empty, rather than fail the run. `Validate` parses the flags with the default
+command, as it does the plain-folder ones.
+
+**A program's own ending names the row.** A terminal that is not `pass`
+reaches the session typed (`run.ProgramEndedError` → `session.ProgramEnding`):
+`fail` and `budget` end the row on `TaskEndingProgram`, whose reason is the
+program's sentence (`senior-dev did not finish: …`) and which is not a fault;
+`crashed` is `TaskEndingError`, the fault it is.
+
+- **From the chat,** the engine's run (`internal/run`'s `DelegateWorker`) starts
+ that line in the folder the proposal names (`propose_task`'s `ground`) or else
+ the conversation's own.
+- **From a shell,** `codeaf ` becomes the host: it serves the model
+ API itself, readies its folder the same way, and starts the same child.
+
+The two are told apart by the environment. A child of a host has
+`CODEAF_MODEL_API` and `CODEAF_MODEL_TOKEN`; a person's shell has neither.
+
+The child's environment is the parent's with every provider key and model
+redirection codeaf knows of removed (`delegate.ChildEnv`). The program passes its
+environment on to every command its model runs, so a key left there would be one
+any model-written shell line could print.
+
+## 3. The model API — the only road to a model
+
+For each run codeaf serves an OpenAI-style chat-completions API at
+`CODEAF_MODEL_API` (a base URL), opened by the bearer token in
+`CODEAF_MODEL_TOKEN` and by nothing else. It lives in `internal/provider`, the
+one package codeaf's funnel law lets spell a model route. Every call:
+
+1. is refused before it is made when the run's dollar ceiling is reached, with
+ HTTP 402 (a status senior-dev does not retry). A run a refusal ended is
+ reported as ` reached the run's dollar ceiling of $X: …`, whatever
+ status the program itself wrote, and ends on the run's cost limit;
+2. goes through codeaf's own model funnel, with its router, retries, caching and
+ billing, on the model the program asked for when one of the person's
+ services can serve it, and otherwise on the run's work seat, which the turn
+ names in `Served` (`modelapi.Resolve`; a call is never refused only because
+ the machine does not know the id);
+3. is answered in the OpenRouter shape, `usage.cost` included, streamed with
+ keepalives while a long call is thinking, or as one body when it was not
+ streamed (`response_format` carried);
+4. is banked to the task's spend and the spending ledger, and written to the
+ run's conversation log.
+
+The token dies with the run, so a grandchild that outlives its parent can no
+longer spend. A call's thread is its `prompt_cache_key`, or its
+`x-session-affinity` header when the body carries no key; reasoning effort rides
+codeaf's own effort ladder.
+
+A shell run (`codeaf …`) has no task folder, so its record — the
+conversation log, the program record and the program's stderr — goes to
+`~/.codeaf/v3/carried///`, one folder per run. Its child is started
+with the person's own line plus `--json`, so a command other than the default
+and the command's own flags survive.
+
+## 4. The records — stdout, one JSON object per line
+
+| record | when | fields |
+| --- | --- | --- |
+| `hello` | first | `protocol` (2), `delegate`, `stages` (the whole list, in order) |
+| `stage` | on every phase change | `stage`, `status`, and optionally `data`: a JSON object of at most 1024 bytes (`delegate.StageDataCap`) |
+| `step` | once per finished action | `command` (one line, 200 bytes at most), `observation` (2048 bytes at most), and optionally `tool` (the tool's name), `step` (the program's own id for the part of its process the action served), `exit` (a command's exit code, only for an action that ran one), and `added` and `removed` (the lines an action that changed a file added and removed, only when the program counted them) |
+| `terminal` | last, exactly once, on every path | `status` (`pass`, `fail`, `budget-exhausted`, `crashed`), `message`, `data`: `reason`, `claim`, `observed`, `deliverable`, and anything else |
+
+Any other line is ignored. There is no `spend` record: the model API meters
+every call as it is made, so money has one source of truth and it is not the
+program's word.
+
+**The optional fields are additive, and they are version 2.** The version moves
+only when a record changes meaning (`delegate.ProtocolVersion`); a field a
+reader does not know is ignored like any other, so a reader that predates
+`data`, `tool`, `step` and `exit` reads the same records without them, and a
+program that sends none of them is read exactly as before. They are read
+forgivingly: a `tool`, `step`, `exit`, `added` or `removed` of another JSON shape is left off and
+the step kept, and `data` that is not an object, or is past the cap, is left
+off and the stage kept.
+
+A stage's `data` is a small, curated copy of what the program already knows
+about the phase — senior-dev's is an attempt, a retry count, its checklist's
+counts, the hand-in's size, what its own check found, the model it moved to —
+for a page to say in words; the program's whole account stays on its stderr.
+senior-dev's step ids are `brief`, `explore`, `pin`, `checklist`, `implement`,
+`submit` and `verify` (`internal/seniordev/app`'s `Steps`); the last is the
+project's own build and tests, which senior-dev runs itself with no model after
+the hand-in and when it checks the tree mid-run, each command one step.
+
+A `hello` carrying another protocol number means the engine outlived a rebuild
+and started the new binary as its child. The run is stopped before it spends,
+with the reason `codeaf was rebuilt while this conversation was open …; restart
+codeaf to run `.
+
+## 5. Stop
+
+SIGTERM to the process group, a 15-second grace, then SIGKILL. On SIGTERM the
+program stops starting new work, writes its terminal, and exits. A body that
+returns without writing a terminal gets one written for it (`delegate.RunChild`).
+
+A host that dies without a word — killed, or taken by a closed terminal's
+hangup, which never reaches a child in a process group of its own — sends no
+SIGTERM. The child looks for its parent once a second and, when the process that
+started it is no longer its parent, stops exactly as a SIGTERM would stop it, and
+is ended outright if it is still at work when the grace has passed
+(`delegate.RunChild`'s `watchHost`). A shell run's host itself treats SIGHUP as
+its first ctrl-c.
+
+## 6. The conversation log and the action log
+
+`delegate-conversation.jsonl` in the task's record folder, one `delegate.Turn`
+per model call: the thread, the model asked for and the one that answered, what
+the program sent that the thread's previous call had not, the reply and the tool
+calls, tokens and cost, and codeaf's refusal or the model's failure. A call is
+written when it starts and again when it ends, and a reader keeps the later
+record, so the task page shows the call in flight.
+
+`delegate-actions.jsonl` beside it, one `delegate.Action` per record the program
+wrote — `stage`, `step`, and its ending (`end`: the terminal's status and
+message) — each stamped `at` with the moment codeaf received it, because a
+program's own clock is not trusted and the page merges this log with the
+conversation log, whose times are codeaf's too. The run's worker writes it and
+so does a shell run, into its own record folder; it is capped as the turns are.
+
+**The page draws actions, not the dialogue.** A program's own vocabulary
+(`Delegate.Present`, a reader told every line of the log in order) turns each
+line into what a person reads under the step of the program's process it
+served (`delegate.Shown`); the page merges those with what only the calls know
+— a compaction, a change of the model answering, a refused or failed call — by
+time, and keeps the dialogue of the raw calls one key away. The live step names
+the step the program is in: the word the program's reader gives the latest
+record that named one, and its stage's word before any has.
+
+## 7. What a program may not do
+
+- Ask a person anything. Nobody is at its keyboard. (Later: a tool codeaf runs
+ inside the model API.)
+- Read stdin.
+- Reach a model any way but the model API.
+- Write anything on stdout that is not a record on its own line.
+- For `tree`: touch files outside its workspace, or leave anything in it that is
+ not its work (its own state git-excluded). senior-dev enforces the first for its
+ file tools: `write`, `edit` and `apply_patch` refuse a path outside the
+ workspace, links resolved (`tool.RegistryOptions.ConfineWrites`), while reads
+ stay open. Its shell is not fenced; its prompt says that nothing a shell
+ command changes outside the workspace comes back.
+
+## 8. Built in now for later programs
+
+pr-af and sec-af, looked at on 2026-09-23, would need: plain structured calls
+with `response_format`, many conversations at once (kept apart by thread),
+grandchildren inheriting the API's address and token, quiet stretches of up to
+30 minutes, and text landings with attachments. The first four are in v2 from
+the start; attachments come with the first text program.
diff --git a/internal/config/config.go b/internal/config/config.go
index 9ce82f04aa..bc1484afae 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -360,6 +360,11 @@ func LoadKeyless() (Config, error) { return load(false) }
// though NewSettings(...).Rows() does not list them.
var nonSettingProfileFields = []string{
KeySetupSeen,
+ // The talk lane's borrow row sits beside its lane row and is read by
+ // [LaneBorrowAt], but it is set from the lane page and not from a settings
+ // row of its own. Missing here, every profile the lane page had written
+ // was told at launch that a key codeaf reads was unread.
+ LaneBorrowKey(LaneSlotTalk),
KeySplitPct,
KeyStandingBackground,
KeyResponseAttempts,
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index efc2d2661b..9ea3e63474 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -670,6 +670,18 @@ func TestProfileKeyLedgerLaw(t *testing.T) {
}
}
+// A profile the lane page wrote carries the talk lane's borrow row, which a
+// reader consumes, so it is never reported unread.
+func TestTheLaneBorrowRowIsNotReportedUnread(t *testing.T) {
+ values := map[string]json.RawMessage{
+ LaneSettingKey(LaneSlotTalk): json.RawMessage(`"openrouter"`),
+ LaneBorrowKey(LaneSlotTalk): json.RawMessage(`false`),
+ }
+ if unread := warnUnreadProfileKeys(t.TempDir(), values); len(unread) != 0 {
+ t.Fatalf("the lane rows were reported unread: %v", unread)
+ }
+}
+
// profileKeysWrittenByRows drives every settings row through its own writer, one
// row to one empty profile, and answers every top-level key that landed in the
// file with the row that wrote it.
diff --git a/internal/config/settings.go b/internal/config/settings.go
index ffe531bd45..2114c8fe70 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -1128,6 +1128,12 @@ var OperatorEnvPins = []string{
// would be promising an override that does nothing, which is worse than
// saying nothing at all.
"CODEAF_PROFILE_DIR",
+ // The model API codeaf serves one program's run, and the token for it
+ // (internal/delegate's ChildEnv). codeaf sets them on the child it starts
+ // and nobody else does; they are an address and a credential, so plumbing,
+ // and the footer names them and never shows a value.
+ "CODEAF_MODEL_API",
+ "CODEAF_MODEL_TOKEN",
// The release check's one-launch opt-out and its two mirror addresses
// (internal/update). They are plumbing rather than settings rows: the first
// is a shell's decision not to make a launch request, while the other two
diff --git a/internal/delegate/actions.go b/internal/delegate/actions.go
new file mode 100644
index 0000000000..9f95e93e63
--- /dev/null
+++ b/internal/delegate/actions.go
@@ -0,0 +1,254 @@
+package delegate
+
+// The action log: every stage, step and ending a program reported, one line
+// each, stamped with the moment codeaf received it, kept in the task's own
+// record folder beside the conversation log. The run's worker (internal/run)
+// and the shell verb (cmd/codeaf) write it as the records arrive; the task page
+// reads it (internal/session) and draws the program's work as the actions it
+// took, each under the step of the program's own process it served.
+//
+// THE TIME IS CODEAF'S. A program's records carry no clock of their own that
+// codeaf trusts, and the page merges this log with the conversation log, whose
+// every time is codeaf's too; stamping on receipt is what makes the two one
+// timeline.
+//
+// THE WORDS ARE THE PROGRAM'S. A line keeps the record as the program wrote it
+// — its stage and status, its step id, its data — and the program's own
+// vocabulary ([Delegate.Present]) turns a line into what a person reads, at the
+// moment the page is read. So a program that learns to say a thing better says
+// it better about every run it has made.
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ActionsFile is the log's name inside a task's record folder.
+const ActionsFile = "delegate-actions.jsonl"
+
+// The kinds of line the log holds: a stage record, a step record, and the
+// terminal record's status and message.
+const (
+ ActionStage = "stage"
+ ActionStep = "step"
+ ActionEnd = "end"
+)
+
+// Action is one line of the log.
+type Action struct {
+ // At is when codeaf received the record.
+ At time.Time `json:"at"`
+ Kind string `json:"kind"`
+ // Stage, Status and Data are a stage's; Status is the ending's too.
+ Stage string `json:"stage,omitempty"`
+ Status string `json:"status,omitempty"`
+ Data json.RawMessage `json:"data,omitempty"`
+ // Tool, Step, Command, Observation and Exit are a step's.
+ Tool string `json:"tool,omitempty"`
+ Step string `json:"step,omitempty"`
+ Command string `json:"command,omitempty"`
+ Observation string `json:"observation,omitempty"`
+ Exit *int `json:"exit,omitempty"`
+ // Added and Removed are a step's lines added and removed, when the program
+ // counted them.
+ Added *int `json:"added,omitempty"`
+ Removed *int `json:"removed,omitempty"`
+ // Message is the ending's one sentence.
+ Message string `json:"message,omitempty"`
+}
+
+// StageAction is a stage record as a line of the log, received at at.
+func StageAction(at time.Time, record StageRecord) Action {
+ return Action{At: at, Kind: ActionStage, Stage: record.Stage, Status: record.Status, Data: record.Data}
+}
+
+// StepAction is a step record as a line of the log, received at at.
+func StepAction(at time.Time, record StepRecord) Action {
+ return Action{
+ At: at, Kind: ActionStep, Tool: record.Tool, Step: record.Step,
+ Command: record.Command, Observation: record.Observation, Exit: record.Exit,
+ Added: record.Added, Removed: record.Removed,
+ }
+}
+
+// EndAction is the terminal record as the log's last line: its status and its
+// sentence. The rest of the record is the result's, which the run keeps whole.
+func EndAction(at time.Time, t Terminal) Action {
+ return Action{At: at, Kind: ActionEnd, Status: t.Status, Message: t.Message}
+}
+
+// capped is the line as it is written: every text held to the reader's own
+// caps, the observation and the message to a turn's, and data that is not an
+// object under [StageDataCap] left off.
+func (a Action) capped() Action {
+ a.Command = cut(oneLine(a.Command), commandCap)
+ a.Observation = cut(a.Observation, turnTextCap)
+ a.Message = cut(a.Message, turnTextCap)
+ a.Tool, a.Step = label(a.Tool), label(a.Step)
+ a.Stage, a.Status = label(a.Stage), label(a.Status)
+ a.Data = stageData(a.Data)
+ return a
+}
+
+// AppendAction writes one line to the log in dir, capped, in one write, making
+// the folder when it is not there.
+func AppendAction(dir string, action Action) error {
+ line, err := json.Marshal(action.capped())
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(filepath.Join(dir, ActionsFile), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(append(line, '\n')); err != nil {
+ _ = file.Close()
+ return err
+ }
+ return file.Close()
+}
+
+// ReadActions reads the log in dir in the order it was written, and the last n
+// lines of it (n <= 0 for all). A log that is not there is no actions and no
+// error — a run from before the log existed, or one whose program has said
+// nothing yet — and a line that does not parse is skipped, because a log cut
+// mid-write is still a log.
+func ReadActions(dir string, n int) ([]Action, error) {
+ file, err := os.Open(filepath.Join(dir, ActionsFile))
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ var actions []Action
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 64<<10), maxLineBytes)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ var action Action
+ if json.Unmarshal([]byte(line), &action) != nil || action.Kind == "" {
+ continue
+ }
+ actions = append(actions, action)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ if n > 0 && len(actions) > n {
+ actions = actions[len(actions)-n:]
+ }
+ return actions, nil
+}
+
+// Shown is one action as a person reads it on its program's page: the step it
+// belongs under, the words, and how it came out. A program's own vocabulary
+// makes it ([Delegate.Present]); a program with none is read plainly
+// ([Delegate.Reader]).
+type Shown struct {
+ At time.Time `json:"at"`
+ // Step is the word for the part of the program's process the action
+ // served, printed once at the head of each run of actions in it. Empty is
+ // an action inside whatever step is under way.
+ Step string `json:"step,omitempty"`
+ // Text is the action in words: `read internal/auth/middleware.go`.
+ Text string `json:"text"`
+ // Outcome is how it came out, in a word or two: `passes`, `fails · exit 2`,
+ // `4 files`. Empty when there is nothing to say.
+ Outcome string `json:"outcome,omitempty"`
+ // Detail is the whole of the step as the log kept it — the command or
+ // argument, and what came back — which the page opens under the action's
+ // one line when it is clicked. Empty for a line with nothing more to show.
+ Detail string `json:"detail,omitempty"`
+ // Lines says the action changed a file and counted how: Added and Removed
+ // are its lines added and removed, drawn as `+N,-M` in the diff's own
+ // colours beside the action. False for every other action.
+ Lines bool `json:"lines,omitempty"`
+ Added int `json:"added,omitempty"`
+ Removed int `json:"removed,omitempty"`
+ // Steer marks the program steering its own model — a nudge, a last turn, a
+ // retry after a dropped call, a correction — rather than working through it.
+ Steer bool `json:"steer,omitempty"`
+ // Memory marks the action that says the program compacted its memory, and
+ // Model names the model an action says it moved to, with Reason why. The
+ // page reads both beside the conversation log, which says the same two
+ // things from the model's side, so one compaction or one switch is drawn
+ // once.
+ Memory bool `json:"memory,omitempty"`
+ Model string `json:"model,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// ActionReader reads a program's action log for its page: each line, in the
+// order the log holds them, as the words a person reads ([Shown]), and false
+// for a line the page leaves out. A reader may remember the lines before — a
+// program can only tell its model's second nudge from a retry of the first by
+// what came earlier — so one reader reads one log, from its first line.
+type ActionReader func(Action) (Shown, bool)
+
+// Reader is a fresh reader of this program's action log: through the program's
+// own vocabulary when it has one ([Delegate.Present]), and plainly otherwise —
+// a stage as its name and status, a step as its command with its step id as
+// the step's word and its exit as the outcome, the ending as its sentence. A
+// line with no words is left out, and every line shown keeps the moment codeaf
+// received it.
+func (d Delegate) Reader() ActionReader {
+ read := ActionReader(plainShown)
+ if d.Present != nil {
+ if own := d.Present(); own != nil {
+ read = own
+ }
+ }
+ return func(action Action) (Shown, bool) {
+ shown, ok := read(action)
+ if !ok || strings.TrimSpace(shown.Text) == "" {
+ return Shown{}, false
+ }
+ shown.At = action.At
+ return shown, true
+ }
+}
+
+// plainShown is a program's action read with no vocabulary of its own.
+func plainShown(action Action) (Shown, bool) {
+ switch action.Kind {
+ case ActionStage:
+ text := action.Stage
+ if action.Status != "" {
+ text += " · " + action.Status
+ }
+ return Shown{Text: text}, true
+ case ActionStep:
+ return Shown{Step: action.Step, Text: action.Command, Outcome: ExitWord(action.Exit)}, true
+ case ActionEnd:
+ return Shown{Text: action.Message}, true
+ }
+ return Shown{}, false
+}
+
+// ExitWord is how a command came out, in the words every program's page uses:
+// `passes` for an exit of 0, `fails · exit N` for any other, and nothing for an
+// action that ran no command or learned no exit.
+func ExitWord(exit *int) string {
+ if exit == nil {
+ return ""
+ }
+ if *exit == 0 {
+ return "passes"
+ }
+ return "fails · exit " + strconv.Itoa(*exit)
+}
diff --git a/internal/delegate/actions_test.go b/internal/delegate/actions_test.go
new file mode 100644
index 0000000000..44d985cab2
--- /dev/null
+++ b/internal/delegate/actions_test.go
@@ -0,0 +1,94 @@
+package delegate
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// THE ACTION LOG KEEPS EVERY RECORD AS IT WAS RECEIVED, stamped with codeaf's
+// own clock, in the order it arrived: a stage with its data, a step with its
+// tool, step and exit, and the ending's status and sentence.
+func TestTheActionLogKeepsEachRecordInTheOrderItArrived(t *testing.T) {
+ dir := t.TempDir()
+ at := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC)
+ exit := 1
+ for _, action := range []Action{
+ StageAction(at, StageRecord{Stage: "submit", Status: "frozen", Data: []byte(`{"patch_files":4}`)}),
+ StepAction(at.Add(time.Second), StepRecord{Command: "bash: go test ./...", Observation: "FAIL", Tool: "bash", Step: "verify", Exit: &exit}),
+ EndAction(at.Add(2*time.Second), Terminal{Status: StatusPass, Message: "submitted a change"}),
+ } {
+ if err := AppendAction(dir, action); err != nil {
+ t.Fatal(err)
+ }
+ }
+ actions, err := ReadActions(dir, 0)
+ if err != nil || len(actions) != 3 {
+ t.Fatalf("actions %+v, err %v", actions, err)
+ }
+ if a := actions[0]; a.Kind != ActionStage || a.Stage != "submit" || string(a.Data) != `{"patch_files":4}` || !a.At.Equal(at) {
+ t.Fatalf("the stage read back as %+v", a)
+ }
+ if a := actions[1]; a.Kind != ActionStep || a.Tool != "bash" || a.Step != "verify" || a.Exit == nil || *a.Exit != 1 {
+ t.Fatalf("the step read back as %+v", a)
+ }
+ if a := actions[2]; a.Kind != ActionEnd || a.Status != StatusPass || a.Message != "submitted a change" {
+ t.Fatalf("the ending read back as %+v", a)
+ }
+ if last, _ := ReadActions(dir, 1); len(last) != 1 || last[0].Kind != ActionEnd {
+ t.Fatalf("the last line = %+v", last)
+ }
+}
+
+// A LINE IS WRITTEN CAPPED, and a log that is not there — a run from before the
+// log existed — is no actions and no error.
+func TestAnActionIsWrittenCappedAndAMissingLogIsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ if actions, err := ReadActions(dir, 0); err != nil || len(actions) != 0 {
+ t.Fatalf("a missing log read %v, %v", actions, err)
+ }
+ err := AppendAction(dir, Action{Kind: ActionStep, Command: "bash:\n" + strings.Repeat("x", 500),
+ Observation: strings.Repeat("é", 3000), Data: []byte(`[1]`)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ actions, _ := ReadActions(dir, 0)
+ a := actions[0]
+ if len(a.Command) > commandCap || strings.Contains(a.Command, "\n") || len(a.Observation) > turnTextCap || a.Data != nil {
+ t.Fatalf("the line was written %d/%d bytes, data %s", len(a.Command), len(a.Observation), a.Data)
+ }
+}
+
+// A PROGRAM WITH NO VOCABULARY IS READ PLAINLY: a stage as its name and
+// status, a step as its command under its own step id with its exit said, and
+// the ending as its sentence.
+func TestAProgramWithNoVocabularyIsReadPlainly(t *testing.T) {
+ plain := Delegate{Name: "fake"}
+ at := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC)
+ exit := 2
+ for _, tc := range []struct {
+ action Action
+ want Shown
+ }{
+ {StageAction(at, StageRecord{Stage: "implement", Status: "running"}), Shown{At: at, Text: "implement · running"}},
+ {StepAction(at, StepRecord{Command: "bash: go test", Step: "verify", Exit: &exit}), Shown{At: at, Step: "verify", Text: "bash: go test", Outcome: "fails · exit 2"}},
+ {EndAction(at, Terminal{Status: StatusFail, Message: "it did not finish"}), Shown{At: at, Text: "it did not finish"}},
+ } {
+ got, ok := plain.Reader()(tc.action)
+ if !ok || got != tc.want {
+ t.Errorf("Show(%+v) = %+v, %v; want %+v", tc.action, got, ok, tc.want)
+ }
+ }
+ zero := 0
+ if ExitWord(&zero) != "passes" || ExitWord(nil) != "" {
+ t.Fatalf("exit words = %q, %q", ExitWord(&zero), ExitWord(nil))
+ }
+ // A PROGRAM'S OWN VOCABULARY IS WHAT IT SAYS, and nothing with no words is
+ // drawn.
+ own := Delegate{Name: "fake", Present: func() ActionReader {
+ return func(Action) (Shown, bool) { return Shown{Text: ""}, true }
+ }}
+ if _, ok := own.Reader()(StageAction(at, StageRecord{Stage: "x"})); ok {
+ t.Fatal("an action with no words was shown")
+ }
+}
diff --git a/internal/delegate/builtin/builtin.go b/internal/delegate/builtin/builtin.go
new file mode 100644
index 0000000000..7f79641923
--- /dev/null
+++ b/internal/delegate/builtin/builtin.go
@@ -0,0 +1,54 @@
+// Package builtin is the list of programs this build carries — the one place a
+// program becomes part of codeaf (internal/delegate). The chat's rows, the
+// command line's verbs, the prompt's hand-off paragraph and the manual all read
+// this list, so a program is added by one package and one line here, and a
+// program not on it does not exist anywhere.
+//
+// IT IS A LIST IN CODE, NOT A FOLDER ON THE MACHINE. Nothing is installed, and
+// no program can differ from the codeaf it ships in. Programs from outside the
+// binary are a later road; the first draft of it, manifests read from disk,
+// is kept on the tag delegate-manifest-v1.
+//
+// THIS PACKAGE IS WHERE THE WEIGHT IS. It imports every program it carries, so
+// only the doors that must hand a program to something — the command line and
+// the chat's launch, both in cmd/codeaf — import it. internal/session and
+// internal/run are handed the list and never import it, or a test binary of
+// either would carry every program's engine.
+package builtin
+
+import (
+ "sort"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+)
+
+// list is what this build carries: [carried] for this platform, or what a
+// test put in its place ([Override]).
+var list = carried
+
+// All is every program this build carries, sorted by name, which is the order
+// lists draw them.
+func All() []delegate.Delegate {
+ out := append([]delegate.Delegate(nil), list...)
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+// Find answers the program with this name.
+func Find(name string) (delegate.Delegate, bool) {
+ for _, program := range list {
+ if program.Name == name {
+ return program, true
+ }
+ }
+ return delegate.Delegate{}, false
+}
+
+// Override puts programs in the list's place and answers the restore. It is
+// for tests of the doors that read the list, which need a program to exist
+// that is not senior-dev's whole engine; nothing in the product calls it.
+func Override(programs []delegate.Delegate) (restore func()) {
+ previous := list
+ list = programs
+ return func() { list = previous }
+}
diff --git a/internal/delegate/builtin/builtin_test.go b/internal/delegate/builtin/builtin_test.go
new file mode 100644
index 0000000000..67df900724
--- /dev/null
+++ b/internal/delegate/builtin/builtin_test.go
@@ -0,0 +1,48 @@
+package builtin
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+)
+
+// Every program this build carries is one that can run: its definition
+// validates, and no two share a name.
+func TestEveryCarriedProgramIsWellDefined(t *testing.T) {
+ seen := map[string]bool{}
+ for _, program := range All() {
+ if err := program.Validate(); err != nil {
+ t.Errorf("%v", err)
+ }
+ if seen[program.Name] {
+ t.Errorf("two programs are called %s", program.Name)
+ }
+ seen[program.Name] = true
+ }
+}
+
+// EVERY HELP PAGE A CARRIED PROGRAM PRINTS FITS EIGHTY CELLS: its own page and
+// each command's, codeaf's shared flags included, the width every page of
+// `codeaf --help` is held to.
+func TestEveryCarriedProgramsHelpFitsEightyColumns(t *testing.T) {
+ for _, program := range All() {
+ lines := [][]string{{"--help"}}
+ for _, command := range program.Commands {
+ lines = append(lines, []string{command.Name, "--help"})
+ }
+ for _, line := range lines {
+ var out bytes.Buffer
+ if _, err := delegate.Parse(program, line, &out); err != delegate.ErrHelp {
+ t.Fatalf("%s %v: err = %v, want the help", program.Name, line, err)
+ }
+ for at, printed := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") {
+ if width := utf8.RuneCountInString(printed); width > 80 {
+ t.Errorf("codeaf %s %s line %d draws %d cells: %q", program.Name, strings.Join(line, " "), at+1, width, printed)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/delegate/builtin/carried_unix.go b/internal/delegate/builtin/carried_unix.go
new file mode 100644
index 0000000000..52e54d097a
--- /dev/null
+++ b/internal/delegate/builtin/carried_unix.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package builtin
+
+import (
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev"
+)
+
+// carried is every program this build carries on a unix: senior-dev, whose
+// engine lives in internal/seniordev.
+var carried = []delegate.Delegate{seniordev.Program}
diff --git a/internal/delegate/builtin/carried_windows.go b/internal/delegate/builtin/carried_windows.go
new file mode 100644
index 0000000000..e2cf947657
--- /dev/null
+++ b/internal/delegate/builtin/carried_windows.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package builtin
+
+import "github.com/Agent-Field/codeaf/internal/delegate"
+
+// carried is empty on Windows. senior-dev's engine uses process groups, file
+// locks and a bash shell, none of which it has ever had a Windows form of, so
+// on Windows it is ABSENT — no row, no verb, no paragraph in the prompt —
+// rather than present and failing every time it is asked.
+var carried []delegate.Delegate
diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go
new file mode 100644
index 0000000000..d93b55f910
--- /dev/null
+++ b/internal/delegate/cli.go
@@ -0,0 +1,309 @@
+package delegate
+
+// The command line every program answers: `codeaf [command] [flags]
+// [--] `. codeaf owns the verb, the dispatch and the four flags every
+// program shares; the program owns its commands and their flags. The same
+// line is what a person types at a shell and what a chat's run starts its
+// child with ([ChildArgs]), so there is one parser for both.
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ErrHelp is Parse's answer when the line asked for help and got it.
+var ErrHelp = flag.ErrHelp
+
+// Invocation is one `codeaf …` line, parsed.
+type Invocation struct {
+ Program Delegate
+ Command Command
+ // Workspace is --dir, absolute; the current folder when it was not given.
+ Workspace string
+ // Ceilings are --max-cost and --max-hours.
+ Ceilings Ceilings
+ // JSON is --json: the records on stdout instead of readable lines. A child
+ // of a host always writes records, so for it the flag only says so aloud.
+ JSON bool
+ // Args is what the flags left: the brief's words.
+ Args []string
+ // Line is the arguments exactly as given after the name, so a host can hand
+ // its child the same line it was handed.
+ Line []string
+ body Body
+}
+
+// Brief is the brief's words, joined.
+func (inv *Invocation) Brief() string { return strings.TrimSpace(strings.Join(inv.Args, " ")) }
+
+// Parse reads the arguments after `codeaf `. The first word picks a
+// command when it names one; otherwise the program's default command runs on
+// the whole line, so `codeaf senior-dev fix the flaky test` is its `run`. Help
+// (`-h`, `--help`, or `help` as the first word) is written to out and answered
+// as ErrHelp.
+func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) {
+ rest := line
+ if len(rest) > 0 {
+ switch rest[0] {
+ case "help", "-h", "-help", "--help":
+ // THE PROGRAM'S OWN PAGE FOR A BARE ASK. `codeaf --help` is
+ // asked before any command is named, so it answers with what the
+ // program is and every command it has; a command's own flags are
+ // one `codeaf --help` away, as the page ends by
+ // saying.
+ Help(program, out)
+ return nil, ErrHelp
+ }
+ }
+ command, named := program.Command(program.Default)
+ if len(rest) > 0 {
+ if c, ok := program.Command(rest[0]); ok {
+ command, named, rest = c, true, rest[1:]
+ }
+ }
+ if !named || command.Bind == nil {
+ return nil, fmt.Errorf("%s has no command %q", program.Name, program.Default)
+ }
+ fs := flag.NewFlagSet(program.Name+" "+command.Name, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ dir := fs.String("dir", "", "the folder to work in (default: the current folder)")
+ cost := fs.Float64("max-cost", 0, "a dollar ceiling; codeaf refuses the call that would cross it")
+ hours := fs.Float64("max-hours", 0, "a ceiling in hours of wall-clock time")
+ asJSON := fs.Bool("json", false, "write the records on stdout instead of readable lines")
+ body := command.Bind(fs)
+ if body == nil {
+ return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, errNoBody)
+ }
+ if err := fs.Parse(rest); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ commandHelp(program, command, fs, out)
+ return nil, ErrHelp
+ }
+ return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, err)
+ }
+ if *cost < 0 || *hours < 0 {
+ return nil, fmt.Errorf("%s %s: a ceiling cannot be negative", program.Name, command.Name)
+ }
+ workspace := *dir
+ if strings.TrimSpace(workspace) == "" {
+ workspace = "."
+ }
+ abs, err := filepath.Abs(workspace)
+ if err != nil {
+ return nil, fmt.Errorf("%s %s: --dir: %w", program.Name, command.Name, err)
+ }
+ return &Invocation{
+ Program: program, Command: command,
+ Workspace: abs,
+ Ceilings: Ceilings{CostUSD: *cost, Hours: *hours},
+ JSON: *asJSON,
+ Args: fs.Args(),
+ Line: append([]string(nil), line...),
+ body: body,
+ }, nil
+}
+
+// ChildArgs is the line a host starts a program's process with, after
+// codeaf's own executable: the name, the default command, --json, the folder,
+// the ceilings that are set, and the brief after `--`, so no word of it can be
+// read as a flag. [Parse] reads it back to the same invocation.
+//
+// AN UNSET CEILING IS NOT ON THE LINE. A program handed `--max-cost 0` might
+// read it as a ceiling of nothing; one handed no flag reads no ceiling.
+//
+// The facts codeaf read about the run put the program's own flags on the line
+// after codeaf's: [Delegate.PlainFolder] for a folder with no git history, and
+// [Delegate.CrewFlags] for the conversation's crew.
+func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings, facts RunFacts) []string {
+ args := []string{program.Name, program.Default, "--json", "--dir", workspace}
+ if ceilings.CostUSD > 0 {
+ args = append(args, "--max-cost", strconv.FormatFloat(ceilings.CostUSD, 'f', -1, 64))
+ }
+ if ceilings.Hours > 0 {
+ args = append(args, "--max-hours", strconv.FormatFloat(ceilings.Hours, 'f', -1, 64))
+ }
+ if facts.Plain {
+ args = append(args, program.PlainFolder...)
+ }
+ if program.CrewFlags != nil && !facts.Crew.IsZero() {
+ args = append(args, program.CrewFlags(facts.Crew)...)
+ }
+ return append(args, "--", brief)
+}
+
+// RunFacts is what codeaf read about a run before it started the program, each
+// of which puts the program's own flags for it on the line ([ChildArgs]).
+type RunFacts struct {
+ // Plain says the folder has no git history.
+ Plain bool
+ // Crew is the conversation's crew; zero for a run no conversation started.
+ Crew Crew
+}
+
+// Help writes a program's help: what it is, its commands, and the flags every
+// command takes.
+//
+// EVERY LINE FITS EIGHTY CELLS, the width codeaf's own help pages are held to
+// (cmd/codeaf's helpwidth law); the build's list holds every carried program's
+// pages to it (internal/delegate/builtin).
+func Help(program Delegate, out io.Writer) {
+ fmt.Fprintf(out, "codeaf %s: %s\n\n", program.Name, program.Summary)
+ fmt.Fprintf(out, "usage:\n codeaf %s [flags] the same as %s\n", program.Name, program.Default)
+ for _, c := range program.Commands {
+ fmt.Fprintf(out, " codeaf %s %s %s\n %s\n", program.Name, c.Name, c.Usage, c.Summary)
+ }
+ fmt.Fprintf(out, "\nflags every command takes:\n")
+ fmt.Fprintf(out, " --dir DIR the folder to work in (default: the current folder)\n")
+ fmt.Fprintf(out, " --max-cost USD a dollar ceiling; codeaf refuses the call that would cross it\n")
+ fmt.Fprintf(out, " --max-hours H a ceiling in hours of wall-clock time\n")
+ fmt.Fprintf(out, " --json write the records on stdout instead of readable lines\n")
+ fmt.Fprintf(out, "\n`codeaf %s --help` lists a command's own flags.\n", program.Name)
+}
+
+// commandHelp is one command's help, with its own flags.
+func commandHelp(program Delegate, command Command, fs *flag.FlagSet, out io.Writer) {
+ fmt.Fprintf(out, "codeaf %s %s %s\n %s\n\nflags:\n", program.Name, command.Name, command.Usage, command.Summary)
+ fs.VisitAll(func(f *flag.Flag) {
+ fmt.Fprintf(out, " --%-14s %s\n", f.Name, f.Usage)
+ })
+}
+
+// RunChild runs a parsed invocation as the child of a host: its records go to
+// stdout as JSON lines and its models come from the environment. It answers
+// the status of the ending it wrote, and the caller turns that into the exit
+// code.
+//
+// EXACTLY ONE TERMINAL, ON EVERY PATH. A body that returns without writing one
+// gets one written for it here — the context's end, the error it returned, or
+// the plain fact that it said nothing — because a host reads a missing
+// terminal as work that did not finish and says only that, and the reason the
+// body knew would be lost.
+//
+// AND IT ENDS WHEN ITS HOST DOES, however the host went ([watchHost]).
+func RunChild(ctx context.Context, inv *Invocation, stdout io.Writer) string {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ finished := make(chan struct{})
+ defer close(finished)
+ go watchHost(ctx, cancel, finished)
+ api, _ := ModelAPIFromEnv()
+ emitter := NewEmitter(stdout)
+ host := &childHost{inv: inv, emitter: emitter, api: api, ending: StatusFail}
+ var err error
+ if !api.Ready() {
+ err = errors.New("this run has no model API: codeaf starts " + inv.Program.Name + " with one, and a shell run hosts its own")
+ } else {
+ err = inv.body(ctx, host, inv.Args)
+ }
+ if !emitter.Ended() {
+ switch {
+ case ctx.Err() != nil:
+ host.Terminal(Ending{Status: StatusFail, Message: "stopped before it finished"})
+ case err != nil:
+ host.Terminal(Ending{Status: StatusCrashed, Message: firstLineOf(err.Error())})
+ default:
+ host.Terminal(Ending{Status: StatusFail, Message: "it ended without saying how"})
+ }
+ }
+ return host.ending
+}
+
+// hostPID reads the process a child's host is; a variable so a test can play
+// a host that goes away.
+var hostPID = os.Getppid
+
+// hostWatch is how often a child looks for its host.
+var hostWatch = time.Second
+
+// hostGrace is how long a child whose host has gone is given to end on its
+// own, and hostGoneExit what ends it after that; variables so a test can
+// play a program that ignores its stop without ending the test binary.
+var (
+ hostGrace = DefaultGrace
+ hostGoneExit = func() { os.Exit(1) }
+)
+
+// watchHost ends a child's context when the process that started it is gone,
+// and returns when the context ends, or the child has finished, either way.
+//
+// A HOST KILLED OUTRIGHT SENDS NOTHING. A child runs in a process group of its
+// own ([Run]), so a closed terminal's hangup never reaches it, and a host that
+// was killed, or died of that hangup, never sends the SIGTERM a stop is: the
+// child worked on in the person's folder, released by nobody, while the next
+// run took the folder the dead host's lock had let go. The child learns it
+// here instead — the parent it was started by is no longer its parent — and
+// stops exactly as a stop would have stopped it, its terminal written on the
+// way out; a record written to the dead host's pipe after that ends it anyway.
+//
+// AND THE LADDER A HOST'S OWN STOP KEEPS IS KEPT ([Run]): the stop, the grace,
+// then the end. A program still at work when the grace has passed — a restore,
+// a last test run — is ended outright, because nobody is left to do it and the
+// folder it is working in is free for the next run. One that finished within
+// the grace (finished closed) is left to leave on its own.
+func watchHost(ctx context.Context, cancel context.CancelFunc, finished <-chan struct{}) {
+ host := hostPID()
+ tick := time.NewTicker(hostWatch)
+ defer tick.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick.C:
+ if hostPID() == host {
+ continue
+ }
+ cancel()
+ grace := time.NewTimer(hostGrace)
+ defer grace.Stop()
+ select {
+ case <-finished:
+ case <-grace.C:
+ hostGoneExit()
+ }
+ return
+ }
+ }
+}
+
+// childHost is the Host of a program running as a child: records to stdout,
+// models from the environment.
+type childHost struct {
+ inv *Invocation
+ emitter *Emitter
+ api ModelAPI
+ ending string
+}
+
+func (h *childHost) Workspace() string { return h.inv.Workspace }
+func (h *childHost) Ceilings() Ceilings { return h.inv.Ceilings }
+func (h *childHost) Models() ModelAPI { return h.api }
+func (h *childHost) Hello(stages []string) {
+ _ = h.emitter.Hello(h.inv.Program.Name, stages)
+}
+func (h *childHost) Stage(stage StageRecord) { _ = h.emitter.Stage(stage) }
+func (h *childHost) Step(step StepRecord) { _ = h.emitter.Step(step) }
+func (h *childHost) Terminal(end Ending) {
+ if h.emitter.Ended() {
+ return
+ }
+ if !KnownStatus(end.Status) {
+ end.Status = StatusCrashed
+ }
+ h.ending = end.Status
+ _ = h.emitter.Terminal(end)
+}
+
+// firstLineOf is an error's first line, because an ending's message is one
+// sentence.
+func firstLineOf(s string) string {
+ line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
+ return line
+}
diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go
new file mode 100644
index 0000000000..1b66c30f7b
--- /dev/null
+++ b/internal/delegate/cli_test.go
@@ -0,0 +1,364 @@
+package delegate
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "flag"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// testProgram is a program with two commands, the default one taking a flag
+// of its own, whose body reports what it was handed through the host.
+func testProgram(body Body) Delegate {
+ if body == nil {
+ body = func(context.Context, Host, []string) error { return nil }
+ }
+ return Delegate{
+ Name: "fake", Summary: "a fake program for the tests", Default: "run", Page: "fake",
+ Guide: "For the tests' fake work, with a brief that names what it touches.",
+ Commands: []Command{{
+ Name: "run", Usage: "[flags] -- ", Summary: "does the whole task",
+ Bind: func(fs *flag.FlagSet) Body {
+ variant := fs.String("variant", "", "how hard the model thinks")
+ return func(ctx context.Context, host Host, args []string) error {
+ if *variant != "" {
+ host.Stage(StageRecord{Stage: "variant", Status: *variant})
+ }
+ return body(ctx, host, args)
+ }
+ },
+ }, {
+ Name: "check", Usage: "", Summary: "says whether it could run",
+ Bind: func(fs *flag.FlagSet) Body { return body },
+ }},
+ }
+}
+
+func TestParseRunsTheDefaultCommandOnABareBrief(t *testing.T) {
+ inv, err := Parse(testProgram(nil), []string{"--max-cost", "5", "fix", "the", "flaky", "test"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inv.Command.Name != "run" || inv.Brief() != "fix the flaky test" || inv.Ceilings.CostUSD != 5 {
+ t.Fatalf("invocation = %+v", inv)
+ }
+ if inv.Workspace == "" || inv.Workspace[0] != '/' {
+ t.Fatalf("workspace = %q, want the current folder, absolute", inv.Workspace)
+ }
+}
+
+func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) {
+ inv, err := Parse(testProgram(nil), []string{"run", "--variant", "high", "--dir", "/tmp", "--", "--not-a-flag"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inv.Command.Name != "run" || inv.Workspace != "/tmp" || inv.Brief() != "--not-a-flag" {
+ t.Fatalf("invocation = %+v", inv)
+ }
+ if other, err := Parse(testProgram(nil), []string{"check"}, &bytes.Buffer{}); err != nil || other.Command.Name != "check" {
+ t.Fatalf("check = %+v %v", other, err)
+ }
+}
+
+// The line a host starts its child with is the line Parse reads back.
+func TestChildArgsParseBackToTheSameInvocation(t *testing.T) {
+ program := testProgram(nil)
+ line := ChildArgs(program, "/work", "add a --flag to the parser", Ceilings{CostUSD: 2.5, Hours: 1}, RunFacts{})
+ if line[0] != "fake" {
+ t.Fatalf("line = %q, want the program's name first", line)
+ }
+ inv, err := Parse(program, line[1:], &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inv.Command.Name != "run" || !inv.JSON || inv.Workspace != "/work" || inv.Brief() != "add a --flag to the parser" ||
+ inv.Ceilings != (Ceilings{CostUSD: 2.5, Hours: 1}) {
+ t.Fatalf("invocation = %+v", inv)
+ }
+}
+
+// A plain folder puts the program's own flags for one on the line, before the
+// brief and where its command parses them, and a folder with history puts
+// nothing there.
+func TestChildArgsCarryThePlainFolderFlagsOnlyForAPlainFolder(t *testing.T) {
+ program := testProgram(nil)
+ program.PlainFolder = []string{"--variant", "plain"}
+ if err := program.Validate(); err != nil {
+ t.Fatalf("a program whose plain-folder flags its command takes is refused: %v", err)
+ }
+ if line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{}); strings.Contains(strings.Join(line, " "), "--variant") {
+ t.Fatalf("a folder with history carried the plain-folder flags: %q", line)
+ }
+ line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{Plain: true})
+ if got := strings.Join(line, " "); !strings.HasSuffix(got, "--variant plain -- the brief") {
+ t.Fatalf("line = %q, want the plain-folder flags just before the brief", got)
+ }
+ // Parse refuses a flag its command does not declare, so reading the line
+ // back is the command taking them.
+ inv, err := Parse(program, line[1:], &bytes.Buffer{})
+ if err != nil || inv.Brief() != "the brief" {
+ t.Fatalf("the line read back as %+v, %v", inv, err)
+ }
+}
+
+// The conversation's crew reaches the program in its own flags, before the
+// brief, and a run with no crew carries none.
+func TestChildArgsCarryTheCrewInTheProgramsOwnFlags(t *testing.T) {
+ program := testProgram(nil)
+ program.CrewFlags = func(crew Crew) []string { return []string{"--variant", crew.Hands} }
+ if err := program.Validate(); err != nil {
+ t.Fatalf("a program whose crew flags its command takes is refused: %v", err)
+ }
+ if line := strings.Join(ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{}), " "); strings.Contains(line, "--variant") {
+ t.Fatalf("a run with no crew carried crew flags: %q", line)
+ }
+ line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{Crew: Crew{Hands: "vendor/hands"}})
+ if got := strings.Join(line, " "); !strings.HasSuffix(got, "--variant vendor/hands -- the brief") {
+ t.Fatalf("line = %q, want the crew's flags just before the brief", got)
+ }
+ program.CrewFlags = func(Crew) []string { return []string{"--models", "x"} }
+ if err := program.Validate(); err == nil || !strings.Contains(err.Error(), "crew flags") {
+ t.Fatalf("Validate = %v, want crew flags its command does not take refused", err)
+ }
+}
+
+// A plain-folder flag the default command does not declare would end every
+// run on a plain folder at its first line, so the definition is refused.
+func TestValidateRefusesPlainFolderFlagsTheCommandDoesNotTake(t *testing.T) {
+ program := testProgram(nil)
+ program.PlainFolder = []string{"--in-place"}
+ if err := program.Validate(); err == nil || !strings.Contains(err.Error(), "plain folder flags") {
+ t.Fatalf("Validate = %v, want the plain folder flags refused", err)
+ }
+}
+
+func TestParseWritesHelpAndSaysSo(t *testing.T) {
+ for _, line := range [][]string{{"--help"}, {"help"}, {"run", "-h"}} {
+ var out bytes.Buffer
+ if _, err := Parse(testProgram(nil), line, &out); !errors.Is(err, ErrHelp) {
+ t.Fatalf("%q: err = %v, want ErrHelp", line, err)
+ }
+ if !strings.Contains(out.String(), "codeaf fake") {
+ t.Fatalf("%q: help = %q", line, out.String())
+ }
+ }
+ var out bytes.Buffer
+ _, _ = Parse(testProgram(nil), []string{"run", "--help"}, &out)
+ if !strings.Contains(out.String(), "--variant") {
+ t.Fatalf("a command's help lacks its own flag:\n%s", out.String())
+ }
+ // A bare ask is the program's own page, with every command on it.
+ out.Reset()
+ _, _ = Parse(testProgram(nil), []string{"--help"}, &out)
+ if !strings.Contains(out.String(), "flags every command takes") || !strings.Contains(out.String(), "codeaf fake check") {
+ t.Fatalf("a bare --help is not the program's own page:\n%s", out.String())
+ }
+}
+
+// EXACTLY ONE TERMINAL, ON EVERY PATH: a body that ends without one gets one,
+// and a body's own is the only one written.
+func TestRunChildWritesExactlyOneTerminal(t *testing.T) {
+ t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1")
+ t.Setenv(EnvModelToken, "token")
+ cases := []struct {
+ name string
+ body Body
+ status string
+ }{
+ {"its own", func(ctx context.Context, host Host, args []string) error {
+ host.Hello([]string{"work"})
+ host.Terminal(Ending{Status: StatusPass, Message: "done", Claim: "it works"})
+ host.Terminal(Ending{Status: StatusFail, Message: "a second"})
+ return nil
+ }, StatusPass},
+ {"an error", func(ctx context.Context, host Host, args []string) error {
+ return errors.New("the engine broke\nwith a trace")
+ }, StatusCrashed},
+ {"nothing said", func(ctx context.Context, host Host, args []string) error { return nil }, StatusFail},
+ }
+ for _, tc := range cases {
+ inv, err := Parse(testProgram(tc.body), []string{"b"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ status := RunChild(context.Background(), inv, &stdout)
+ reading, err := Read(&stdout, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != tc.status || reading.Terminal == nil || reading.Terminal.Status != tc.status {
+ t.Fatalf("%s: status %q terminal %+v, want %q", tc.name, status, reading.Terminal, tc.status)
+ }
+ if strings.Count(stdout.String(), `"terminal"`) > 1 {
+ t.Fatalf("%s: more than one terminal:\n%s", tc.name, stdout.String())
+ }
+ }
+}
+
+func TestRunChildRefusesToRunWithoutAModelAPI(t *testing.T) {
+ t.Setenv(EnvModelAPI, "")
+ ran := false
+ inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error { ran = true; return nil }), []string{"b"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ if status := RunChild(context.Background(), inv, &stdout); status != StatusCrashed || ran {
+ t.Fatalf("status %q ran %v, want crashed before the body", status, ran)
+ }
+}
+
+func TestValidateRefusesADefinitionThatCouldNotRun(t *testing.T) {
+ good := testProgram(func(context.Context, Host, []string) error { return nil })
+ if err := good.Validate(); err != nil {
+ t.Fatalf("a good definition refused: %v", err)
+ }
+ bad := good
+ bad.Default = "missing"
+ if err := bad.Validate(); err == nil || !strings.Contains(err.Error(), "default command") {
+ t.Fatalf("err = %v", err)
+ }
+ shadow := good
+ shadow.Commands = []Command{{Name: "run", Bind: func(fs *flag.FlagSet) Body {
+ fs.String("dir", "", "")
+ return func(context.Context, Host, []string) error { return nil }
+ }}}
+ if err := shadow.Validate(); err == nil || !strings.Contains(err.Error(), "--dir") {
+ t.Fatalf("err = %v, want the shared flag named", err)
+ }
+}
+
+// A PROGRAM DESCRIBES ITSELF TO THE MODEL THAT HANDS IT WORK, in one paragraph
+// the conversation's fixed prefix can afford: a program with no guide would be
+// listed by its name alone, one with line breaks would break the list it is an
+// item of, and one past GuideMax would be paid for on every request of every
+// turn of every conversation that carries it.
+func TestValidateHoldsTheGuideToOneAffordableParagraph(t *testing.T) {
+ good := testProgram(nil)
+ for _, c := range []struct {
+ name, guide, want string
+ }{
+ {"empty", " ", "the guide is empty"},
+ {"two paragraphs", "For one thing.\n\nAnd another.", "no line breaks"},
+ {"too long", strings.Repeat("x", GuideMax+1), "held to"},
+ } {
+ program := good
+ program.Guide = c.guide
+ if err := program.Validate(); err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("%s: err = %v, want it to say %q", c.name, err, c.want)
+ }
+ }
+ program := good
+ program.Guide = strings.Repeat("x", GuideMax)
+ if err := program.Validate(); err != nil {
+ t.Fatalf("a guide of exactly GuideMax bytes refused: %v", err)
+ }
+}
+
+// A CHILD WHOSE HOST HAS GONE STOPS. Its host can die without a word — killed,
+// or taken by a closed terminal's hangup, which never reaches a child in a
+// process group of its own — and the child used to work on in the person's
+// folder after the folder's hold had gone with the host. Now the child sees
+// its parent change and ends as a stop would end it, its terminal written.
+func TestAChildStopsWhenItsHostIsGone(t *testing.T) {
+ t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1")
+ t.Setenv(EnvModelToken, "token")
+ previousPID, previousWatch, previousGrace, previousExit := hostPID, hostWatch, hostGrace, hostGoneExit
+ t.Cleanup(func() {
+ hostPID, hostWatch, hostGrace, hostGoneExit = previousPID, previousWatch, previousGrace, previousExit
+ })
+ hostGoneExit = func() { t.Error("a child that stopped within its grace was ended outright") }
+ hostGrace = time.Second
+ var mu sync.Mutex
+ host := 4242
+ hostPID = func() int {
+ mu.Lock()
+ defer mu.Unlock()
+ return host
+ }
+ hostWatch = 5 * time.Millisecond
+ started := make(chan struct{})
+ inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error {
+ close(started)
+ <-ctx.Done()
+ return ctx.Err()
+ }), []string{"b"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ done := make(chan string, 1)
+ go func() { done <- RunChild(context.Background(), inv, &stdout) }()
+ <-started
+ select {
+ case <-done:
+ t.Fatal("the child stopped while its host was still there")
+ case <-time.After(50 * time.Millisecond):
+ }
+ mu.Lock()
+ host = 1
+ mu.Unlock()
+ select {
+ case status := <-done:
+ reading, err := Read(&stdout, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != StatusFail || reading.Terminal == nil || reading.Terminal.Message != "stopped before it finished" {
+ t.Fatalf("the child ended %q with %+v, want it stopped", status, reading.Terminal)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("the child worked on after its host was gone")
+ }
+}
+
+// AND ONE THAT IGNORES THE STOP IS ENDED WHEN ITS GRACE HAS PASSED, the ladder
+// a host's own stop keeps: nobody is left to send the SIGKILL, and the folder
+// it is working in is free for the next run.
+func TestAChildThatIgnoresItsGoneHostIsEndedAfterTheGrace(t *testing.T) {
+ t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1")
+ t.Setenv(EnvModelToken, "token")
+ previousPID, previousWatch, previousGrace, previousExit := hostPID, hostWatch, hostGrace, hostGoneExit
+ t.Cleanup(func() {
+ hostPID, hostWatch, hostGrace, hostGoneExit = previousPID, previousWatch, previousGrace, previousExit
+ })
+ var mu sync.Mutex
+ calls := 0
+ hostPID = func() int {
+ mu.Lock()
+ defer mu.Unlock()
+ calls++
+ if calls == 1 {
+ return 4242
+ }
+ return 1
+ }
+ hostWatch, hostGrace = 5*time.Millisecond, 20*time.Millisecond
+ ended := make(chan struct{})
+ hostGoneExit = func() { close(ended) }
+ release := make(chan struct{})
+ inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error {
+ <-release
+ return nil
+ }), []string{"b"}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ go func() {
+ RunChild(context.Background(), inv, &bytes.Buffer{})
+ close(done)
+ }()
+ select {
+ case <-ended:
+ case <-time.After(5 * time.Second):
+ t.Fatal("a child that ignored its gone host was never ended")
+ }
+ close(release)
+ <-done
+}
diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go
new file mode 100644
index 0000000000..c34d1fc57d
--- /dev/null
+++ b/internal/delegate/conversation.go
@@ -0,0 +1,247 @@
+package delegate
+
+// The conversation log: one record per model call a program makes through its
+// model API, kept in the task's own record folder beside the trajectory. The
+// API writes it (internal/provider) and the task page reads it
+// (internal/session), and neither may import the other, so the record and the
+// one door each side uses live here.
+//
+// THIS IS WHAT MAKES A PROGRAM'S WORK VISIBLE. To the program the API is an
+// ordinary model backend; to codeaf the program is a very particular person
+// asking it things. Every exchange is therefore a turn — what the program sent
+// that it had not sent before, and what the model answered — and the page
+// draws the turns as the conversation they are.
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// ConversationFile is the log's name inside a task's record folder.
+const ConversationFile = "delegate-conversation.jsonl"
+
+// ProgramFile names, inside a task's record folder, which program the run
+// handed its task to and the stages it said it would move through (its
+// `hello`). The worker writes it when the hello arrives and again, whole, when
+// the program's process is gone — then whether or not a hello ever came, so a
+// program that died early still has its clock; the task page reads it to say
+// whose conversation it is drawing, after the run as well as during.
+const ProgramFile = "delegate-program.json"
+
+// ProgramRecord is ProgramFile's content.
+type ProgramRecord struct {
+ Name string `json:"name"`
+ Stages []string `json:"stages,omitempty"`
+ // CeilingUSD is the dollar ceiling the run handed the program, zero for
+ // none. It is written here because the run works it out when it starts and
+ // keeps it nowhere a page could read it afterwards, and a page that shows
+ // the spend without the ceiling beside it leaves out half the reading.
+ CeilingUSD float64 `json:"ceiling_usd,omitempty"`
+ // StartedAt and EndedAt are the program's own clock: the instant codeaf
+ // started its process and the instant that process was gone, written by
+ // whoever ran it (the run's worker, or the shell verb). They are the ONE
+ // record of how long the program itself ran, because every other pair of
+ // times near a run brackets something else — the store is seeded before
+ // the copy is cut, and the row settles after the landing. EndedAt is zero
+ // while the program runs, and both are zero in a record written before
+ // they existed, which a reader draws as no time rather than a wrong one.
+ StartedAt time.Time `json:"started_at,omitzero"`
+ EndedAt time.Time `json:"ended_at,omitzero"`
+}
+
+// WriteProgram writes the record, whole, making the folder when it is not
+// there.
+func WriteProgram(dir string, record ProgramRecord) error {
+ data, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ temp := filepath.Join(dir, ProgramFile+".tmp")
+ if err := os.WriteFile(temp, append(data, '\n'), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(temp, filepath.Join(dir, ProgramFile))
+}
+
+// ReadProgram reads the record; ok is false for a run that handed its task to
+// no program, or whose program has neither said hello nor ended yet.
+func ReadProgram(dir string) (ProgramRecord, bool) {
+ data, err := os.ReadFile(filepath.Join(dir, ProgramFile))
+ if err != nil {
+ return ProgramRecord{}, false
+ }
+ var record ProgramRecord
+ if json.Unmarshal(data, &record) != nil || record.Name == "" {
+ return ProgramRecord{}, false
+ }
+ return record, true
+}
+
+// MainThread is the thread a call belongs to when the program gave it no
+// other: its one long conversation.
+const MainThread = "main"
+
+// Turn is one model call a program made through its model API. A call is
+// written twice under one Seq — when it starts, with no Ended, and when it
+// ends — and a reader keeps the later, which is how the page shows a call in
+// flight without a second file.
+type Turn struct {
+ Seq int `json:"seq"`
+ // Thread tells conversations apart when a program holds more than one at
+ // once (a summary of its own history, a helper agent): the call's cache key
+ // or its own id, MainThread when it gave none.
+ Thread string `json:"thread,omitempty"`
+ Started time.Time `json:"started"`
+ Ended time.Time `json:"ended,omitempty"`
+ // Model is the model the program asked for; Served is the one that
+ // answered, when codeaf's router answered with another.
+ Model string `json:"model"`
+ Served string `json:"served,omitempty"`
+ // Sent is what the program sent that the thread's previous call did not:
+ // its brief first, then its tools' results and its own words. Restarted is
+ // true when the program rewrote its history instead of adding to it (a
+ // compaction), so Sent is then everything it sent.
+ Sent []Said `json:"sent,omitempty"`
+ Restarted bool `json:"restarted,omitempty"`
+ // Reply is the model's text, and Calls the tools it asked the program to run.
+ Reply string `json:"reply,omitempty"`
+ Calls []ToolUse `json:"calls,omitempty"`
+ // The call's size and price, as the funnel metered them.
+ TokensIn int `json:"tokens_in,omitempty"`
+ TokensOut int `json:"tokens_out,omitempty"`
+ Cached int `json:"cached,omitempty"`
+ CostUSD float64 `json:"cost_usd,omitempty"`
+ // Refused is codeaf's own refusal — the ceiling, a run that has ended — set
+ // when the call never reached a model. Failed is the model's side failing.
+ Refused string `json:"refused,omitempty"`
+ Failed string `json:"failed,omitempty"`
+}
+
+// InFlight answers whether the call has not come back yet.
+func (t Turn) InFlight() bool { return t.Ended.IsZero() && t.Refused == "" && t.Failed == "" }
+
+// Said is one message a program sent: whose it is and its words. A tool's
+// result carries the tool it answers.
+type Said struct {
+ // Role is "system", "user" or "tool", as the program sent it.
+ Role string `json:"role"`
+ Tool string `json:"tool,omitempty"`
+ Text string `json:"text"`
+}
+
+// ToolUse is one tool a model asked the program to run, with its arguments on
+// one line.
+type ToolUse struct {
+ Name string `json:"name"`
+ Args string `json:"args,omitempty"`
+}
+
+// The caps a turn is written with. A page draws the first lines of these; the
+// whole of a message is the program's own record, never codeaf's.
+const (
+ turnTextCap = 2048
+ turnSaidMax = 12
+ turnCallsMax = 16
+ turnArgsCap = 200
+)
+
+// capped is the turn as it is written: every text cut on a rune boundary, the
+// newest messages kept when there are too many, the tool calls bounded.
+func (t Turn) capped() Turn {
+ t.Reply = cut(t.Reply, turnTextCap)
+ if len(t.Sent) > turnSaidMax {
+ t.Sent = t.Sent[len(t.Sent)-turnSaidMax:]
+ }
+ sent := make([]Said, len(t.Sent))
+ for i, said := range t.Sent {
+ said.Text = cut(said.Text, turnTextCap)
+ sent[i] = said
+ }
+ t.Sent = sent
+ if len(t.Calls) > turnCallsMax {
+ t.Calls = t.Calls[:turnCallsMax]
+ }
+ calls := make([]ToolUse, len(t.Calls))
+ for i, call := range t.Calls {
+ call.Args = cut(oneLine(call.Args), turnArgsCap)
+ calls[i] = call
+ }
+ t.Calls = calls
+ if t.Thread == "" {
+ t.Thread = MainThread
+ }
+ return t
+}
+
+// AppendTurn writes one turn to the log in dir, capped, in one write, making
+// the folder when it is not there.
+func AppendTurn(dir string, turn Turn) error {
+ line, err := json.Marshal(turn.capped())
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(filepath.Join(dir, ConversationFile), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(append(line, '\n')); err != nil {
+ _ = file.Close()
+ return err
+ }
+ return file.Close()
+}
+
+// ReadTurns reads the log in dir: every call once, in the order they started,
+// each as its latest record says, and the last n of them (n <= 0 for all). A
+// log that is not there is no turns and no error, because a run that has not
+// called a model yet has said nothing; a line that does not parse is skipped,
+// because a log cut mid-write is still a log.
+func ReadTurns(dir string, n int) ([]Turn, error) {
+ file, err := os.Open(filepath.Join(dir, ConversationFile))
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ latest := map[int]int{}
+ var turns []Turn
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 64<<10), maxLineBytes)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ var turn Turn
+ if json.Unmarshal([]byte(line), &turn) != nil {
+ continue
+ }
+ if at, seen := latest[turn.Seq]; seen {
+ turns[at] = turn
+ continue
+ }
+ latest[turn.Seq] = len(turns)
+ turns = append(turns, turn)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ if n > 0 && len(turns) > n {
+ turns = turns[len(turns)-n:]
+ }
+ return turns, nil
+}
diff --git a/internal/delegate/conversation_test.go b/internal/delegate/conversation_test.go
new file mode 100644
index 0000000000..6b1e43f8a2
--- /dev/null
+++ b/internal/delegate/conversation_test.go
@@ -0,0 +1,77 @@
+package delegate
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// A call is written when it starts and again when it ends; the reader keeps
+// the later record in the earlier one's place, so a call in flight is seen and
+// then replaced by its answer.
+func TestReadTurnsKeepsEachCallsLatestRecordInStartOrder(t *testing.T) {
+ dir := t.TempDir()
+ start := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC)
+ for _, turn := range []Turn{
+ {Seq: 1, Started: start, Model: "m", Sent: []Said{{Role: "user", Text: "the brief"}}},
+ {Seq: 1, Started: start, Ended: start.Add(time.Second), Model: "m", Reply: "reading the tests", Calls: []ToolUse{{Name: "bash", Args: "go test ./..."}}},
+ {Seq: 2, Started: start.Add(2 * time.Second), Model: "m"},
+ } {
+ if err := AppendTurn(dir, turn); err != nil {
+ t.Fatal(err)
+ }
+ }
+ turns, err := ReadTurns(dir, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(turns) != 2 || turns[0].Reply != "reading the tests" || turns[0].InFlight() || !turns[1].InFlight() {
+ t.Fatalf("turns = %+v", turns)
+ }
+ if turns[0].Thread != MainThread {
+ t.Fatalf("thread = %q, want the main one for a call that named none", turns[0].Thread)
+ }
+ if last, _ := ReadTurns(dir, 1); len(last) != 1 || last[0].Seq != 2 {
+ t.Fatalf("last = %+v", last)
+ }
+}
+
+func TestATurnIsWrittenCapped(t *testing.T) {
+ dir := t.TempDir()
+ sent := make([]Said, 20)
+ for i := range sent {
+ sent[i] = Said{Role: "tool", Tool: "bash", Text: strings.Repeat("é", 3000)}
+ }
+ if err := AppendTurn(dir, Turn{Seq: 1, Sent: sent, Reply: strings.Repeat("x", 5000), Calls: []ToolUse{{Name: "bash", Args: "a\nb " + strings.Repeat("y", 500)}}}); err != nil {
+ t.Fatal(err)
+ }
+ turns, _ := ReadTurns(dir, 0)
+ turn := turns[0]
+ if len(turn.Sent) != turnSaidMax || len(turn.Sent[0].Text) > turnTextCap || len(turn.Reply) != turnTextCap {
+ t.Fatalf("sent %d, first %d bytes, reply %d bytes", len(turn.Sent), len(turn.Sent[0].Text), len(turn.Reply))
+ }
+ if args := turn.Calls[0].Args; len(args) > turnArgsCap || strings.Contains(args, "\n") {
+ t.Fatalf("args = %q, want one line, capped", args)
+ }
+}
+
+func TestReadTurnsOfARunThatCalledNothingIsEmpty(t *testing.T) {
+ turns, err := ReadTurns(t.TempDir(), 0)
+ if err != nil || len(turns) != 0 {
+ t.Fatalf("turns %v err %v", turns, err)
+ }
+}
+
+func TestTheProgramRecordReadsBackAndIsAbsentBeforeTheHello(t *testing.T) {
+ dir := t.TempDir()
+ if _, ok := ReadProgram(dir); ok {
+ t.Fatal("a run with no hello read a program")
+ }
+ if err := WriteProgram(dir, ProgramRecord{Name: "senior-dev", Stages: []string{"implement", "submit"}}); err != nil {
+ t.Fatal(err)
+ }
+ record, ok := ReadProgram(dir)
+ if !ok || record.Name != "senior-dev" || len(record.Stages) != 2 {
+ t.Fatalf("record = %+v %v", record, ok)
+ }
+}
diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go
new file mode 100644
index 0000000000..d01ff0f80f
--- /dev/null
+++ b/internal/delegate/delegate.go
@@ -0,0 +1,319 @@
+// Package delegate is what codeaf needs of the programs it carries and can hand
+// a whole task to — senior-dev first (docs/design/delegate/PROTOCOL.md): what
+// one IS (a Go value in the build's list, internal/delegate/builtin), the
+// command line every one of them answers (`codeaf …`), the records a
+// running one writes on its stdout and the one reader over them, the model API
+// that is its only road to a model, the log of the conversation it holds over
+// that road, and the launch of it as a child process that streams, stops on
+// SIGTERM and ends with one terminal record.
+//
+// "DELEGATE" IS A WORKING TITLE. Everything a person reads names the program
+// itself — `/senior-dev`, `codeaf senior-dev`, its own manual page — and only
+// code says delegate, where a later rename is one package move.
+//
+// A PROGRAM CODEAF CARRIES IS STILL A PROGRAM APART. It is compiled into this
+// binary, but it runs as a child process of it (`codeaf run --json …`),
+// so a crash in its engine cannot take the chat down, and it reaches a model
+// only through the API codeaf serves it for that one run, so it never holds a
+// key. What runs one AS A WORKER of a run — the live step, the trajectory, the
+// spend bank — is internal/run's; nothing here knows what a task is.
+//
+// THIS PACKAGE IS A LEAF ON PURPOSE. The session door lists the programs and
+// checks a name; the run engine seats one; the command line runs one; none of
+// them may import the others, so what they share lives here and imports none
+// of them.
+package delegate
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+)
+
+// The two things a program can leave behind.
+const (
+ // LandsTree is a program that edits files in the folder it is given and
+ // leaves its changes there: in a git repository on a branch codeaf cut for
+ // the run and left checked out, with what it left uncommitted committed
+ // onto that branch when it ends (internal/session's programfolder.go).
+ LandsTree = "tree"
+ // LandsText is a program that changes nothing in the folder and puts its
+ // answer in the terminal record's deliverable: codeaf folds the text into
+ // the conversation the way a quick task's answer arrives.
+ LandsText = "text"
+)
+
+// Delegate is one program this build carries. It is a value in the build's
+// list (internal/delegate/builtin), never a file on the machine: there is
+// nothing to install, and no version of it that differs from the codeaf it
+// ships in.
+type Delegate struct {
+ // Name is one lowercase word with single hyphens: the chat command
+ // (`/ `), the command line's verb (`codeaf `) and the
+ // word every row says out loud.
+ Name string
+ // Summary is one sentence saying what it does, in a person's words: the
+ // command row's tail and its line in `codeaf --help`.
+ Summary string
+ // Guide is the program describing itself to the model that hands it work:
+ // what it is for, what its brief must hold, and what it needs of its
+ // folder. The conversation prints it under the program's name, where the
+ // model reads which programs it can name in `via`, and says nothing about
+ // the program of its own.
+ //
+ // THE PROGRAM OWNS WHAT IS TRUE OF IT, AND CODEAF OWNS WHAT IS TRUE OF
+ // EVERY PROGRAM. The folder a program works in, where its work is left and
+ // the fact that nobody can be asked anything are codeaf's mechanics, stated
+ // once beside the list; a guide that restated them would be one more copy
+ // to drift. A second program brings its own guide, and the conversation's
+ // page never has to learn its name.
+ //
+ // IT RIDES EVERY REQUEST OF EVERY TURN, because the paragraph is part of
+ // the conversation's fixed prefix (internal/session's prefixbudget_test.go
+ // weighs it), so it is one paragraph of at most [GuideMax] bytes.
+ Guide string
+ // Lands is LandsTree or LandsText. Empty reads as LandsTree, because a
+ // program that edits a tree is the one this was built for.
+ Lands string
+ // PlainFolder is the flags the default command takes to work in a folder
+ // with no git history, which codeaf puts on the line itself when the folder
+ // it hands a tree program is one ([ChildArgs]). Empty is a program that
+ // needs no flag for it, or cannot work there and says so in its ending.
+ //
+ // CODEAF DECIDES, BECAUSE CODEAF KNOWS. The folder is the one the task was
+ // proposed on, and whether the program works there on a branch of its own
+ // is read by codeaf before the program starts: a folder with no history, or
+ // in a repository at the home folder, is worked in without git, and the
+ // program is told so on its line. The program says only how it is told, so
+ // codeaf never has to learn its flag's name.
+ PlainFolder []string
+ // Notes is the folder, relative to the folder it works in, where the
+ // program keeps its own records while it works: its copy of the brief, its
+ // checklist, its session's database and its whole conversation with its
+ // model. Empty is a program that keeps nothing there.
+ //
+ // THE NOTES ARE MOVED OUT OF THE FOLDER. A program works in the person's
+ // folder itself, so its records were left there when it ended — 46 files
+ // for one senior-dev run, a database and the full conversation among them —
+ // where a `git add -A` would commit them and the next run would read them
+ // as its own. codeaf moves the folder this names into the run's own record
+ // folder when the run ends, unless it was there before the run began.
+ Notes string
+ // CrewFlags is the flags the default command takes to use the models of
+ // the conversation's crew ([Crew]), which codeaf puts on the line of every
+ // run it starts from a conversation. Nil is a program that picks its own
+ // models whatever the crew says.
+ //
+ // THE PERSON'S CREW IS THE DEFAULT, AND THE PROGRAM SAYS HOW IT HEARS IT.
+ // A person who set which models do the thinking and the typing expects a
+ // program they hand work to to use them too, rather than a list of its
+ // own they never chose; codeaf knows the crew and nothing of the program's
+ // flags, so the program turns the one into the other.
+ CrewFlags func(Crew) []string
+ // StageWords is the word a person reads for each stage the program reports
+ // (its `stage` record), keyed by the stage's own name. The task's row and
+ // the line over its conversation show the word, never the name: a program's
+ // stages are its machinery — senior-dev's say `agent-runtime` and
+ // `router-cancellation` — and this house draws no machinery vocabulary.
+ // A stage with no word leaves the word shown before it standing, so a
+ // program's inner phases need not each be named. Nil shows every stage by
+ // its own name, for a program that has not said.
+ StageWords map[string]string
+ // Present is the program's own vocabulary for its task's page: it makes a
+ // reader ([ActionReader]) that turns each line of its action log
+ // ([Action]) — a stage, a step or its ending — into the words a person
+ // reads, under the word for the step of its process it served ([Shown]),
+ // and answers false for a line the page leaves out. Nil reads every line
+ // plainly ([Delegate.Reader]).
+ //
+ // THE PROGRAM KNOWS WHAT ITS RECORDS MEAN, AND CODEAF KNOWS HOW A PAGE IS
+ // DRAWN. A stage named `submit` with `patch_files` in its data is
+ // senior-dev's machinery; that it reads `handed in its work · 4 files` is
+ // senior-dev's to say, once, beside the words it gives its stages. The page
+ // draws whatever a program says here, and the same step word leads the
+ // task's row while the program is in that step.
+ Present func() ActionReader
+ // Default is the command a bare brief runs: `/ ` in the chat
+ // and `codeaf ` in a shell. It names one of Commands.
+ Default string
+ // Commands is the program's own verbs, each with its own flags. codeaf owns
+ // the dispatch and the flags every program shares; the program owns these.
+ Commands []Command
+ // Page is the name of its page in the chat's manual (internal/manual/chat):
+ // what it does, how to ask it, what a run costs, where the work lands. It is
+ // compiled in with the rest of the manual, so the manual law's own gates
+ // hold it to that.
+ Page string
+}
+
+// Command is one verb a program answers to:
+// `codeaf [flags] -- `.
+type Command struct {
+ Name string
+ // Usage is the shape of the line after the command's name, for its help:
+ // `[flags] -- `.
+ Usage string
+ Summary string
+ // Bind declares the command's own flags on fs and answers its body, which
+ // reads them once the line has been parsed. It is called once per
+ // invocation, so the values live in the closure and never in package
+ // state. codeaf's shared flags (--dir, --max-cost, --max-hours, --json) are
+ // already on fs; a command may not declare them again.
+ Bind func(fs *flag.FlagSet) Body
+}
+
+// Body is a command's work. It runs to its ending and reports through the host
+// — the ending included, as one [Host.Terminal] — and answers an error only
+// for a failure it could not put into that record itself. args is what the
+// flags left on the line: the brief's words.
+type Body func(ctx context.Context, host Host, args []string) error
+
+// nameShape is the one shape a name may have: lowercase letters, digits and
+// single hyphens, starting with a letter. It is a command word twice over — a
+// slash command and a shell verb — so it has to be something a person can
+// type without quoting.
+var nameShape = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`)
+
+// Crew is the models a conversation's crew seats, by what each is for, as ids
+// on the service codeaf's model API speaks for (`vendor/model`), with no
+// effort suffix. An empty field is a seat the crew leaves unset.
+type Crew struct {
+ // Brain is the planning seat: the model the crew thinks hardest with.
+ Brain string
+ // Hands is the working seat: the model the crew does the work with.
+ Hands string
+ // Light is the cheap seat: summaries, and whatever needs no depth.
+ Light string
+ // Asked is the models the person asked this run to work with, in their
+ // words' order, already resolved to ids. When it is set it is the working
+ // seat in place of Hands, and a program may not swap any of it for another:
+ // one it cannot use is a refusal, said before anything is spent.
+ Asked []string
+}
+
+// IsZero says the crew names no model at all, so no flag is owed for it.
+func (c Crew) IsZero() bool {
+ return c.Brain == "" && c.Hands == "" && c.Light == "" && len(c.Asked) == 0
+}
+
+// GuideMax is the most bytes a program's [Delegate.Guide] may take. It is a
+// paragraph a model reads on every turn of every conversation that carries the
+// program, so it is held to what a model needs to choose the program and brief
+// it, and the program's manual page carries the rest.
+const GuideMax = 400
+
+// sharedFlags are the flags codeaf puts on every command's line. A command
+// declaring one of them again would panic inside the flag package at parse
+// time, so Validate refuses it by name first.
+var sharedFlags = []string{"dir", "max-cost", "max-hours", "json"}
+
+// Validate names the first thing wrong with a program's definition in a
+// sentence the person who wrote it can act on. The build's own test runs it on
+// every program the list carries (internal/delegate/builtin), so a definition
+// that could not run never reaches a person.
+func (d Delegate) Validate() error {
+ if !nameShape.MatchString(d.Name) {
+ return fmt.Errorf("%q is not a program name: one lowercase word, letters, digits and single hyphens", d.Name)
+ }
+ if strings.TrimSpace(d.Summary) == "" {
+ return fmt.Errorf("%s: the summary is empty, and it is what the command row says", d.Name)
+ }
+ switch guide := strings.TrimSpace(d.Guide); {
+ case guide == "":
+ return fmt.Errorf("%s: the guide is empty, so the model that hands it work is told nothing but its name", d.Name)
+ case strings.Contains(guide, "\n"):
+ return fmt.Errorf("%s: the guide is one paragraph and has no line breaks, because it is printed as one item of a list", d.Name)
+ case len(guide) > GuideMax:
+ return fmt.Errorf("%s: the guide is %d bytes; it rides every request of every turn, so it is held to %d", d.Name, len(guide), GuideMax)
+ }
+ switch d.Lands {
+ case "", LandsTree, LandsText:
+ default:
+ return fmt.Errorf("%s: lands is %q; it is %q or %q", d.Name, d.Lands, LandsTree, LandsText)
+ }
+ if strings.TrimSpace(d.Page) == "" {
+ return fmt.Errorf("%s: it names no manual page, and the chat can only say what a page says", d.Name)
+ }
+ if len(d.Commands) == 0 {
+ return fmt.Errorf("%s: it has no commands, so there is nothing to run", d.Name)
+ }
+ seen := map[string]bool{}
+ for _, c := range d.Commands {
+ if !nameShape.MatchString(c.Name) {
+ return fmt.Errorf("%s: %q is not a command name", d.Name, c.Name)
+ }
+ if seen[c.Name] {
+ return fmt.Errorf("%s: the command %q is defined twice", d.Name, c.Name)
+ }
+ seen[c.Name] = true
+ if c.Bind == nil {
+ return fmt.Errorf("%s %s: the command has no body", d.Name, c.Name)
+ }
+ fs := flag.NewFlagSet(d.Name+" "+c.Name, flag.ContinueOnError)
+ if c.Bind(fs) == nil {
+ return fmt.Errorf("%s %s: binding the command answered no body", d.Name, c.Name)
+ }
+ for _, shared := range sharedFlags {
+ if fs.Lookup(shared) != nil {
+ return fmt.Errorf("%s %s: --%s is codeaf's own flag and may not be declared again", d.Name, c.Name, shared)
+ }
+ }
+ }
+ if !seen[d.Default] {
+ return fmt.Errorf("%s: the default command %q is not one of its commands", d.Name, d.Default)
+ }
+ if err := d.validateLineFlags(); err != nil {
+ return err
+ }
+ return nil
+}
+
+// validateLineFlags holds the flags codeaf puts on the program's line for it —
+// for a plain folder, and for the conversation's crew — to its default command:
+// a flag the command does not take would end every such run at its first line,
+// so it fails here, in the build's own test.
+func (d Delegate) validateLineFlags() error {
+ command, _ := d.Command(d.Default)
+ parses := func(flags []string) bool {
+ fs := flag.NewFlagSet(d.Name+" "+command.Name, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ command.Bind(fs)
+ return fs.Parse(flags) == nil && fs.NArg() == 0
+ }
+ if d.CrewFlags != nil {
+ for _, sample := range []Crew{
+ {Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"},
+ {Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}},
+ } {
+ if flags := d.CrewFlags(sample); !parses(flags) {
+ return fmt.Errorf("%s: the crew flags %q are not flags its %s command takes", d.Name, strings.Join(flags, " "), command.Name)
+ }
+ }
+ }
+ if len(d.PlainFolder) > 0 && !parses(d.PlainFolder) {
+ return fmt.Errorf("%s: the plain folder flags %q are not flags its %s command takes", d.Name, strings.Join(d.PlainFolder, " "), command.Name)
+ }
+ return nil
+}
+
+// LandsTree answers whether this program's work is a tree to land, which is
+// the reading of an empty Lands too.
+func (d Delegate) LandsTree() bool { return d.Lands == "" || d.Lands == LandsTree }
+
+// Command finds one of the program's commands by name.
+func (d Delegate) Command(name string) (Command, bool) {
+ for _, c := range d.Commands {
+ if c.Name == name {
+ return c, true
+ }
+ }
+ return Command{}, false
+}
+
+// errNoBody is what binding a command without a body answers, so a definition
+// Validate never saw still fails in words rather than with a nil call.
+var errNoBody = errors.New("the command has no body")
diff --git a/internal/delegate/emit.go b/internal/delegate/emit.go
new file mode 100644
index 0000000000..1480158e3c
--- /dev/null
+++ b/internal/delegate/emit.go
@@ -0,0 +1,161 @@
+package delegate
+
+// The writing half of the records, for the program's side of the pipe. The
+// reader (protocol.go) is the parent's; this is what a program running as
+// codeaf's child calls through its [Host], so the two halves are one package
+// and cannot disagree about a field's spelling.
+
+import (
+ "encoding/json"
+ "io"
+ "sync"
+)
+
+// Ending is a program's result as it writes it: the terminal record, in
+// fields rather than a map, so a program cannot misspell the one record the
+// whole protocol exists for.
+type Ending struct {
+ // Status is StatusPass, StatusFail, StatusBudget or StatusCrashed.
+ Status string
+ // Message is one sentence saying why.
+ Message string
+ // CostUSD is the program's own reading of what it spent, zero for none.
+ // codeaf's model API meters every call itself; this figure is kept for the
+ // record and never trusted over that one.
+ CostUSD float64
+ // Reason is the longer reason, when there is one.
+ Reason string
+ // Claim is what the program's model said it did, and Observed is what the
+ // program itself verified. They are two witnesses and stay two fields.
+ Claim string
+ Observed string
+ // Deliverable is the answer text of a program that lands text.
+ Deliverable string
+ // Extra is any other data the program wants on the record. It never
+ // overrides a field above.
+ Extra map[string]any
+}
+
+// record is the Ending on the wire.
+func (e Ending) record() map[string]any {
+ data := map[string]any{}
+ for key, value := range e.Extra {
+ data[key] = value
+ }
+ set := func(key, value string) {
+ if value != "" {
+ data[key] = value
+ }
+ }
+ if e.CostUSD > 0 {
+ data["cost_usd"] = e.CostUSD
+ }
+ set("reason", e.Reason)
+ set("claim", e.Claim)
+ set("observed", e.Observed)
+ set("deliverable", e.Deliverable)
+ return map[string]any{"type": RecordTerminal, "status": e.Status, "message": e.Message, "data": data}
+}
+
+// Emitter writes a program's records on its stdout: one JSON object per line,
+// each written whole under one lock, so two goroutines of the program can
+// never interleave half a line of each.
+//
+// THE TERMINAL IS WRITTEN AT MOST ONCE. A second is dropped here rather than
+// sent for the reader to drop, so a program's own "and one more for luck" on
+// its way out cannot become the record a person reads.
+type Emitter struct {
+ mu sync.Mutex
+ w io.Writer
+ ended bool
+ err error
+}
+
+// NewEmitter writes to w, which for a running program is its stdout.
+func NewEmitter(w io.Writer) *Emitter { return &Emitter{w: w} }
+
+// Hello writes the first record.
+func (e *Emitter) Hello(name string, stages []string) error {
+ return e.write(map[string]any{"type": RecordHello, "protocol": ProtocolVersion, "delegate": name, "stages": stages})
+}
+
+// Stage writes a phase change, with its data when it is an object the reader
+// will keep ([StageDataCap]) and without it otherwise, so the record a program
+// writes is the record that arrives.
+func (e *Emitter) Stage(stage StageRecord) error {
+ record := map[string]any{"type": RecordStage, "stage": stage.Stage, "status": stage.Status}
+ if data := stageData(stage.Data); data != nil {
+ record["data"] = data
+ }
+ return e.write(record)
+}
+
+// Step writes one finished action, capped the way the reader caps it, so what
+// the program meant to say is what arrives. The optional fields are written
+// only when they say something.
+func (e *Emitter) Step(step StepRecord) error {
+ record := map[string]any{"type": RecordStep, "command": cut(oneLine(step.Command), commandCap)}
+ if step.Observation != "" {
+ record["observation"] = cut(step.Observation, observationCap)
+ }
+ if tool := label(step.Tool); tool != "" {
+ record["tool"] = tool
+ }
+ if id := label(step.Step); id != "" {
+ record["step"] = id
+ }
+ if step.Exit != nil {
+ record["exit"] = *step.Exit
+ }
+ if step.Added != nil {
+ record["added"] = *step.Added
+ }
+ if step.Removed != nil {
+ record["removed"] = *step.Removed
+ }
+ return e.write(record)
+}
+
+// Terminal writes the result, once.
+func (e *Emitter) Terminal(end Ending) error {
+ e.mu.Lock()
+ if e.ended {
+ e.mu.Unlock()
+ return nil
+ }
+ e.ended = true
+ e.mu.Unlock()
+ return e.write(end.record())
+}
+
+// Ended answers whether the terminal has been written.
+func (e *Emitter) Ended() bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.ended
+}
+
+// Err is the first write that failed, if any. A program whose stdout is gone
+// has nobody left to tell; the error is kept so its ending can say so.
+func (e *Emitter) Err() error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.err
+}
+
+func (e *Emitter) write(record map[string]any) error {
+ line, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ line = append(line, '\n')
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if _, err := e.w.Write(line); err != nil {
+ if e.err == nil {
+ e.err = err
+ }
+ return err
+ }
+ return nil
+}
diff --git a/internal/delegate/emit_test.go b/internal/delegate/emit_test.go
new file mode 100644
index 0000000000..a74804f4cf
--- /dev/null
+++ b/internal/delegate/emit_test.go
@@ -0,0 +1,50 @@
+package delegate
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// What the emitter writes is what the reader reads: one package, one spelling.
+func TestTheEmitterWritesWhatTheReaderReads(t *testing.T) {
+ var stdout bytes.Buffer
+ emitter := NewEmitter(&stdout)
+ _ = emitter.Hello("senior-dev", []string{"implement", "submit"})
+ exit := 2
+ _ = emitter.Stage(StageRecord{Stage: "implement", Status: "running", Data: []byte(`{"attempt":1}`)})
+ _ = emitter.Stage(StageRecord{Stage: "ship", Status: "unchanged", Data: []byte(`"not an object"`)})
+ _ = emitter.Step(StepRecord{Command: "bash: go test\n./...", Observation: "ok", Tool: "bash", Step: "explore", Exit: &exit})
+ _ = emitter.Terminal(Ending{Status: StatusPass, Message: "submitted", CostUSD: 0.42, Claim: "tests pass", Observed: "3 of 3 commands passed", Extra: map[string]any{"claim": "overridden?", "commits": 4}})
+ _ = emitter.Terminal(Ending{Status: StatusFail, Message: "never written"})
+ sink := &recorder{}
+ reading, err := Read(&stdout, sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reading.Hello == nil || reading.Hello.Protocol != ProtocolVersion || reading.LastStage != "ship" || reading.Steps != 1 {
+ t.Fatalf("reading = %+v", reading)
+ }
+ if sink.steps[0] != "bash: go test ./...→ok" {
+ t.Fatalf("step = %q", sink.steps[0])
+ }
+ if step := sink.stepRecords[0]; step.Tool != "bash" || step.Step != "explore" || step.Exit == nil || *step.Exit != 2 {
+ t.Fatalf("step record = %+v, want its tool, step and exit through the wire", step)
+ }
+ if string(sink.stageRecords[0].Data) != `{"attempt":1}` || sink.stageRecords[1].Data != nil {
+ t.Fatalf("stage data = %s and %s, want the object and nothing for the string", sink.stageRecords[0].Data, sink.stageRecords[1].Data)
+ }
+ if strings.Contains(stdout.String(), "not an object") {
+ t.Fatalf("the emitter wrote data the reader would drop:\n%s", stdout.String())
+ }
+ end := reading.Terminal
+ if end == nil || end.Status != StatusPass || end.Claim() != "tests pass" || end.Observed() != "3 of 3 commands passed" {
+ t.Fatalf("terminal = %+v", end)
+ }
+ if cost, _ := end.CostUSD(); cost != 0.42 {
+ t.Fatalf("cost = %v", cost)
+ }
+ if reading.Ignored != 0 || strings.Contains(stdout.String(), "never written") {
+ t.Fatalf("a second terminal was written")
+ }
+}
diff --git a/internal/delegate/host.go b/internal/delegate/host.go
new file mode 100644
index 0000000000..15091e1e45
--- /dev/null
+++ b/internal/delegate/host.go
@@ -0,0 +1,106 @@
+package delegate
+
+// The host: everything a running program may ask of codeaf, and the
+// environment its process starts in.
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/env"
+ "github.com/Agent-Field/codeaf/internal/modelsource"
+)
+
+// The model API's two names in a program's environment: the OpenAI-style base
+// URL codeaf serves this one run, and the token that opens it and nothing else.
+// They are the ONLY road to a model a program has.
+const (
+ EnvModelAPI = "CODEAF_MODEL_API"
+ EnvModelToken = "CODEAF_MODEL_TOKEN"
+)
+
+// Host is what a running program asks codeaf for. Its body is handed one and
+// reports through it: the records go to codeaf, and the models come from it.
+type Host interface {
+ // Workspace is the folder the program works in, absolute.
+ Workspace() string
+ // Ceilings are the limits codeaf set for this run. The program keeps them
+ // itself so it can end cleanly, and codeaf enforces them whatever it does.
+ Ceilings() Ceilings
+ // Hello, Stage, Step and Terminal are the records (protocol.go). Hello
+ // comes first and Terminal last, once.
+ Hello(stages []string)
+ Stage(stage StageRecord)
+ Step(step StepRecord)
+ Terminal(end Ending)
+ // Models is this run's model API.
+ Models() ModelAPI
+}
+
+// Ceilings are a run's limits. Zero is none.
+type Ceilings struct {
+ CostUSD float64
+ Hours float64
+}
+
+// Elapsed is the hours as a duration, zero for none.
+func (c Ceilings) Elapsed() time.Duration {
+ return time.Duration(c.Hours * float64(time.Hour))
+}
+
+// ModelAPI is the model API codeaf serves one run: an OpenAI-style base URL and
+// the bearer token that opens it. A program in codeaf's own tree builds its
+// route through internal/provider, the one package codeaf's funnel law lets
+// spell a model route; a program outside it appends the route the way every
+// OpenAI client does.
+type ModelAPI struct {
+ BaseURL string
+ Token string
+}
+
+// Ready answers whether there is an API to call.
+func (m ModelAPI) Ready() bool {
+ return strings.TrimSpace(m.BaseURL) != "" && strings.TrimSpace(m.Token) != ""
+}
+
+// Authorize puts the token on a request the program sends to the API.
+func (m ModelAPI) Authorize(req *http.Request) { req.Header.Set("Authorization", "Bearer "+m.Token) }
+
+// ModelAPIFromEnv reads the API from this process's environment; ok is false
+// outside a run, which is how `codeaf ` tells a child of a host from a
+// person at a shell.
+func ModelAPIFromEnv() (ModelAPI, bool) {
+ api := ModelAPI{BaseURL: strings.TrimSpace(env.Get(EnvModelAPI)), Token: strings.TrimSpace(env.Get(EnvModelToken))}
+ return api, api.BaseURL != ""
+}
+
+// ChildEnv is the environment a program's process starts in: this process's,
+// with every provider key and model redirection codeaf knows of taken out, and
+// the model API's two names set.
+//
+// NO KEY REACHES A PROGRAM. Taking the keys out is not tidiness: a program
+// hands its environment on to every command its model runs, so a key left
+// here is a key any model-written shell line can print — senior-dev passed its
+// whole environment to its shell tool before it was absorbed. And a
+// redirection left here would let a program reach a model some other way than
+// the API, which is the one road codeaf can meter, refuse at the ceiling and
+// show a person.
+func ChildEnv(api ModelAPI) []string {
+ strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, "OPENAI_API_KEY", modelsource.DefaultSource("").KeyEnv}
+ for _, source := range modelsource.Vendored() {
+ if source.KeyEnv != "" {
+ strip = append(strip, source.KeyEnv)
+ }
+ }
+ environ := env.EnvironWithout(strip...)
+ if api.BaseURL != "" {
+ environ = append(environ, EnvModelAPI+"="+api.BaseURL, EnvModelToken+"="+api.Token)
+ }
+ return environ
+}
+
+// envBaseURL is codeaf's own redirection of its default model service
+// (internal/config). A program must not inherit it: its only address is the
+// model API's.
+const envBaseURL = "CODEAF_BASE_URL"
diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go
new file mode 100644
index 0000000000..559b173344
--- /dev/null
+++ b/internal/delegate/launch.go
@@ -0,0 +1,216 @@
+package delegate
+
+// The launch: one program as a child process, in its own process group, its
+// stdout read as the records and its stderr kept in a file for a person, ended
+// by SIGTERM with a grace and then SIGKILL when the caller's context ends
+// (docs/design/delegate/PROTOCOL.md). The process is codeaf's own executable
+// running the program's verb ([ChildArgs]); what it is started with is the
+// caller's to say, so a test can start a script that speaks the records.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/processgroup"
+)
+
+// DefaultGrace is how long a SIGTERM has to work before SIGKILL follows. It is
+// the job registry's own two seconds plus what a program that has to write a
+// terminal record and close a database needs: senior-dev ships its frozen tree on
+// the way out, and a grace that cut that short would lose the one record the
+// whole protocol exists for.
+const DefaultGrace = 15 * time.Second
+
+// Launch is one run of one program.
+type Launch struct {
+ // Name is the program's name, for the errors this launch writes.
+ Name string
+ // Bin and Args are the process: codeaf's own executable and the program's
+ // line ([ChildArgs]).
+ Bin string
+ Args []string
+ // Env is the child's whole environment ([ChildEnv]). Nil inherits this
+ // process's, which only a test wants: it would hand a program every key.
+ Env []string
+ // Dir is the folder the process starts in.
+ Dir string
+ // StderrPath is the file the program's stderr is appended to. Empty
+ // discards it, which no real caller wants: stderr is where a program says
+ // why it could not start.
+ StderrPath string
+ // Grace overrides DefaultGrace, for a test that must not wait fifteen
+ // seconds for a process that ignores SIGTERM.
+ Grace time.Duration
+}
+
+// Result is what one launch came to.
+type Result struct {
+ Reading Reading
+ // ExitCode is the process's own, -1 when it was ended by a signal or never
+ // ran. The verdict is NOT read from it (§3): a program that failed its task
+ // exits zero with a terminal saying `fail`.
+ ExitCode int
+ // Stopped is true when the caller's context ended the program: SIGTERM,
+ // and SIGKILL when the grace passed. The reading may still hold a terminal
+ // the program wrote inside the grace.
+ Stopped bool
+ // Killed is true when SIGKILL was needed.
+ Killed bool
+ // Elapsed is the process's wall time.
+ Elapsed time.Duration
+}
+
+// ExitedAt is the instant the program's process was gone: the launch's own
+// measure of the process's life laid on the instant the caller started it, and
+// never later than returned, the instant the launch gave its answer back.
+//
+// THE PROGRAM'S WALL TIME IS ITS PROCESS'S, NOT THE DRAIN'S. A launch returns
+// only once stdout is drained, and a helper the program left holding stdout can
+// keep that drain open for the whole grace after the program itself exited. A
+// conversation's run and a shell run both end the program's clock here, so the
+// same program reads the same time on every surface.
+func (r Result) ExitedAt(started, returned time.Time) time.Time {
+ if r.Elapsed > 0 {
+ if exited := started.Add(r.Elapsed); exited.Before(returned) {
+ return exited
+ }
+ }
+ return returned
+}
+
+// ErrNoTerminal is the error a launch answers when the program exited without
+// a terminal record and was not stopped by the caller: the run did not finish
+// in the protocol's terms, whatever the exit code said.
+var ErrNoTerminal = errors.New("the program exited without a terminal record")
+
+// Run starts the program and reads it to its end. It returns when the process
+// has exited and stdout is drained, so nothing of the child outlives the call.
+//
+// A CONTEXT THAT ENDS ENDS THE PROGRAM, in the order the protocol promises:
+// SIGTERM to the group, the grace, SIGKILL. The stdout reader keeps reading
+// through the grace, so a terminal written on the way out is the reading's
+// terminal. The error answered is the context's own, so a run supervisor that
+// reads `context.Canceled` off a worker knows its own ending cut the task.
+func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) {
+ cmd := exec.Command(launch.Bin, launch.Args...)
+ cmd.Env = launch.Env
+ cmd.Dir = launch.Dir
+ cmd.Stdin = nil
+ processgroup.Configure(cmd)
+ stderr, err := openStderr(launch.StderrPath)
+ if err != nil {
+ return Result{ExitCode: -1}, err
+ }
+ defer stderr.Close()
+ cmd.Stderr = stderr
+ // STDOUT IS A PIPE THIS LAUNCH OWNS, not cmd.StdoutPipe: Wait closes that
+ // one the moment the process exits, and bytes still in the kernel's buffer
+ // — a terminal record written a millisecond before exit — would be gone
+ // with it. Here the write end is the child's alone once started, the reader
+ // reads to EOF, and EOF comes when every holder of the write end is gone.
+ stdoutRead, stdoutWrite, err := os.Pipe()
+ if err != nil {
+ return Result{ExitCode: -1}, err
+ }
+ cmd.Stdout = stdoutWrite
+ started := time.Now()
+ if err := cmd.Start(); err != nil {
+ _ = stdoutRead.Close()
+ _ = stdoutWrite.Close()
+ return Result{ExitCode: -1}, fmt.Errorf("start %s: %w", launch.Name, err)
+ }
+ _ = stdoutWrite.Close()
+ group := processgroup.CaptureGroup(cmd.Process.Pid)
+
+ type read struct {
+ reading Reading
+ err error
+ }
+ readDone := make(chan read, 1)
+ go func() {
+ reading, err := Read(stdoutRead, sink)
+ readDone <- read{reading, err}
+ }()
+
+ waitDone := make(chan error, 1)
+ go func() { waitDone <- cmd.Wait() }()
+
+ result := Result{ExitCode: -1}
+ grace := launch.Grace
+ if grace <= 0 {
+ grace = DefaultGrace
+ }
+ var waitErr error
+ select {
+ case waitErr = <-waitDone:
+ case <-ctx.Done():
+ result.Stopped = true
+ _ = group.Terminate()
+ select {
+ case waitErr = <-waitDone:
+ case <-time.After(grace):
+ result.Killed = true
+ _ = group.Kill()
+ waitErr = <-waitDone
+ }
+ }
+ result.Elapsed = time.Since(started)
+ if waitErr == nil {
+ result.ExitCode = 0
+ } else {
+ var exit *exec.ExitError
+ if errors.As(waitErr, &exit) {
+ if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Exited() {
+ result.ExitCode = status.ExitStatus()
+ }
+ }
+ }
+ // THE READER IS GIVEN THE GRACE TO REACH EOF, then the pipe is closed under
+ // it. EOF ordinarily arrives with the exit, but a grandchild the program
+ // left holding stdout — a detached helper — would hold this launch open for
+ // as long as it lived, and a launch that never returns is a run that never
+ // lands.
+ var r read
+ select {
+ case r = <-readDone:
+ case <-time.After(grace):
+ _ = stdoutRead.Close()
+ r = <-readDone
+ }
+ _ = stdoutRead.Close()
+ result.Reading = r.reading
+ if result.Stopped {
+ return result, ctx.Err()
+ }
+ if r.err != nil {
+ return result, fmt.Errorf("read %s's stdout: %w", launch.Name, r.err)
+ }
+ if result.Reading.Terminal == nil {
+ return result, ErrNoTerminal
+ }
+ return result, nil
+}
+
+// openStderr opens the stderr file for append, creating it, or a sink when
+// no path was given.
+func openStderr(path string) (io.WriteCloser, error) {
+ if strings.TrimSpace(path) == "" {
+ return nopCloser{io.Discard}, nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return nil, err
+ }
+ return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+}
+
+type nopCloser struct{ io.Writer }
+
+func (nopCloser) Close() error { return nil }
diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go
new file mode 100644
index 0000000000..b06e026a8f
--- /dev/null
+++ b/internal/delegate/launch_test.go
@@ -0,0 +1,241 @@
+//go:build !windows
+
+package delegate
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakeProgram is a shell script that stands in for codeaf running a program:
+// it writes its argv to the file FAKE_ARGS names, emits a hello, a stage, a
+// v1 spend line (which the reader no longer knows, and ignores) and a step,
+// then runs the body it was given.
+func fakeProgram(t *testing.T, body string) string {
+ t.Helper()
+ dir := t.TempDir()
+ script := filepath.Join(dir, "fake.sh")
+ writeProgram(t, script, strings.Join([]string{
+ `if [ -n "$FAKE_ARGS" ]; then printf '%s\n' "$@" > "$FAKE_ARGS"; fi`,
+ `if [ -n "$FAKE_ENV" ]; then env > "$FAKE_ENV"; fi`,
+ `echo '{"type":"hello","protocol":2,"delegate":"fake","stages":["implement"]}'`,
+ `echo '{"type":"stage","stage":"implement","status":"running"}'`,
+ `echo '{"type":"spend","cost_usd":0.01}'`,
+ `echo '{"type":"step","command":"bash: true","observation":"ok"}'`,
+ `echo 'a note for a person' >&2`,
+ body,
+ }, "\n"))
+ return script
+}
+
+// fakeLaunch is the launch of the fake program the way a worker builds one:
+// the program's line after the executable, and the child's environment.
+func fakeLaunch(t *testing.T, script, workspace, brief string, ceilings Ceilings, api ModelAPI) Launch {
+ t.Helper()
+ program := Delegate{Name: "fake", Default: "run"}
+ return Launch{
+ Name: "fake",
+ Bin: script,
+ Args: ChildArgs(program, workspace, brief, ceilings, RunFacts{}),
+ Env: ChildEnv(api),
+ Dir: workspace,
+ }
+}
+
+func terminalLine(status, message string) string {
+ return `echo '{"type":"terminal","status":"` + status + `","message":"` + message + `","data":{"cost_usd":0.02}}'`
+}
+
+func TestRunStartsTheProgramsLineAndReadsTheTerminal(t *testing.T) {
+ script := fakeProgram(t, terminalLine("pass", "done"))
+ args := filepath.Join(t.TempDir(), "args")
+ t.Setenv("FAKE_ARGS", args)
+ workspace := t.TempDir()
+ stderr := filepath.Join(t.TempDir(), "stderr.log")
+ sink := &recorder{}
+ launch := fakeLaunch(t, script, workspace, "rewrite the thing", Ceilings{CostUSD: 1.5, Hours: 0.25}, ModelAPI{})
+ launch.StderrPath = stderr
+ result, err := Run(context.Background(), launch, sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.ExitCode != 0 || result.Stopped || result.Reading.Terminal == nil || result.Reading.Terminal.Status != StatusPass {
+ t.Fatalf("result = %+v", result)
+ }
+ got, _ := os.ReadFile(args)
+ want := "fake\nrun\n--json\n--dir\n" + workspace + "\n--max-cost\n1.5\n--max-hours\n0.25\n--\nrewrite the thing\n"
+ if string(got) != want {
+ t.Fatalf("argv =\n%s\nwant\n%s", got, want)
+ }
+ if log, _ := os.ReadFile(stderr); !strings.Contains(string(log), "a note for a person") {
+ t.Fatalf("stderr file = %q, want the program's note kept", log)
+ }
+ if sink.hello == nil || sink.hello.Delegate != "fake" || sink.steps[0] != "bash: true→ok" {
+ t.Fatalf("sink = %+v", sink)
+ }
+ // The program's own word about money is not a record any more: the spend
+ // line is the one line the reader dropped.
+ if result.Reading.Ignored != 1 {
+ t.Fatalf("ignored = %d, want the v1 spend line and nothing else", result.Reading.Ignored)
+ }
+}
+
+func TestRunLeavesAnUnsetCeilingOffTheLine(t *testing.T) {
+ script := fakeProgram(t, terminalLine("pass", "done"))
+ args := filepath.Join(t.TempDir(), "args")
+ t.Setenv("FAKE_ARGS", args)
+ workspace := t.TempDir()
+ if _, err := Run(context.Background(), fakeLaunch(t, script, workspace, "b", Ceilings{}, ModelAPI{}), nil); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(args)
+ if string(got) != "fake\nrun\n--json\n--dir\n"+workspace+"\n--\nb\n" {
+ t.Fatalf("argv =\n%s\nwant no --max-cost and no --max-hours at all", got)
+ }
+}
+
+// THE BRIEF IS THE PERSON'S WORDS AND REACHES THE PROGRAM AS WRITTEN: after
+// `--`, one element, whatever it spells — a flag, a placeholder, a key's name.
+func TestRunHandsTheBriefOverVerbatim(t *testing.T) {
+ script := fakeProgram(t, terminalLine("pass", "done"))
+ args := filepath.Join(t.TempDir(), "args")
+ t.Setenv("FAKE_ARGS", args)
+ for _, brief := range []string{
+ "https://github.com/o/r/pull/1 check {{ .Name }} escaping, and {{key}} in {{workspace}}",
+ "--dir /etc --max-cost 999 read these as words",
+ } {
+ if _, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), brief, Ceilings{}, ModelAPI{}), nil); err != nil {
+ t.Fatalf("the launch refused a brief: %v", err)
+ }
+ got, _ := os.ReadFile(args)
+ lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n")
+ if last := lines[len(lines)-1]; last != brief || lines[len(lines)-2] != "--" {
+ t.Fatalf("the brief reached the program as\n%q\nwant it verbatim after --", lines)
+ }
+ }
+}
+
+// NO KEY REACHES A PROGRAM. The child's environment is this process's with
+// every provider key and model redirection taken out and the model API's two
+// names put in.
+func TestTheChildsEnvironmentCarriesTheAPIAndNoKey(t *testing.T) {
+ script := fakeProgram(t, terminalLine("pass", "done"))
+ envFile := filepath.Join(t.TempDir(), "env")
+ t.Setenv("FAKE_ENV", envFile)
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-parent")
+ t.Setenv("OPENAI_API_KEY", "sk-parent")
+ t.Setenv("DEEPSEEK_API_KEY", "sk-deepseek")
+ t.Setenv("CODEAF_BASE_URL", "https://elsewhere.example/v1")
+ t.Setenv("SOMETHING_ELSE", "kept")
+ api := ModelAPI{BaseURL: "http://127.0.0.1:9/v1", Token: "run-token"}
+ if _, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, api), nil); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := os.ReadFile(envFile)
+ environ := string(data)
+ for _, gone := range []string{"sk-or-v1-parent", "sk-parent", "sk-deepseek", "elsewhere.example"} {
+ if strings.Contains(environ, gone) {
+ t.Fatalf("the child inherited %q:\n%s", gone, environ)
+ }
+ }
+ for _, kept := range []string{"SOMETHING_ELSE=kept", EnvModelAPI + "=http://127.0.0.1:9/v1", EnvModelToken + "=run-token"} {
+ if !strings.Contains(environ, kept) {
+ t.Fatalf("the child's environment lacks %q:\n%s", kept, environ)
+ }
+ }
+}
+
+func TestRunAnswersNoTerminalWhenTheProgramExitsWithoutOne(t *testing.T) {
+ script := fakeProgram(t, "exit 3")
+ result, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{}), nil)
+ if !errors.Is(err, ErrNoTerminal) {
+ t.Fatalf("err = %v, want ErrNoTerminal", err)
+ }
+ if result.ExitCode != 3 || result.Reading.LastStage != "implement" {
+ t.Fatalf("result = %+v, want the exit code and the last stage seen kept", result)
+ }
+}
+
+func TestRunTerminatesOnCancelAndKeepsATerminalWrittenInTheGrace(t *testing.T) {
+ // The program traps TERM, writes its terminal and exits; the sleep is what
+ // the signal interrupts.
+ script := fakeProgram(t, strings.Join([]string{
+ `trap '` + strings.ReplaceAll(terminalLine("budget-exhausted", "stopped by the parent"), "'", `'"'"'`) + `; exit 0' TERM`,
+ `sleep 30 &`,
+ `wait $!`,
+ }, "\n"))
+ ctx, cancel := context.WithCancel(context.Background())
+ sink := newRecorder()
+ go func() {
+ // Cancel once the program has said its first word, so the trap is armed.
+ select {
+ case <-sink.spoke:
+ case <-time.After(5 * time.Second):
+ }
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+ }()
+ launch := fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{})
+ launch.Grace = 5 * time.Second
+ result, err := Run(ctx, launch, sink)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err = %v, want the context's own", err)
+ }
+ if !result.Stopped || result.Killed {
+ t.Fatalf("result = %+v, want stopped by SIGTERM and not killed", result)
+ }
+ if result.Reading.Terminal == nil || result.Reading.Terminal.Status != StatusBudget {
+ t.Fatalf("terminal = %+v, want the one the program wrote on its way out", result.Reading.Terminal)
+ }
+}
+
+func TestRunKillsAProgramThatIgnoresTerm(t *testing.T) {
+ script := fakeProgram(t, strings.Join([]string{
+ `trap '' TERM`,
+ `sleep 30`,
+ }, "\n"))
+ // THE PROGRAM MUST HAVE ITS TRAP BEFORE THE STOP ARRIVES. Three hundred
+ // milliseconds was the whole of its life here, and on a loaded box the
+ // shell had not yet run `trap` when TERM came, so it died of the TERM and
+ // the test read a program that honours TERM as one the launch failed to
+ // kill. It is given a second and a half to get there; the grace that
+ // follows is what the test is about.
+ ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
+ defer cancel()
+ started := time.Now()
+ launch := fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{})
+ launch.Grace = 200 * time.Millisecond
+ result, err := Run(ctx, launch, nil)
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("err = %v", err)
+ }
+ if !result.Stopped || !result.Killed || result.Reading.Terminal != nil {
+ t.Fatalf("result = %+v, want stopped, killed, no terminal", result)
+ }
+ if time.Since(started) > 5*time.Second {
+ t.Fatalf("the launch took %s to give up on a program that ignores TERM", time.Since(started))
+ }
+}
+
+func TestRunRefusesAProgramThatIsNotThere(t *testing.T) {
+ _, err := Run(context.Background(), Launch{Name: "gone", Bin: filepath.Join(t.TempDir(), "gone"), Dir: t.TempDir()}, nil)
+ if err == nil || !strings.Contains(err.Error(), "start gone") {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+// writeProgram writes an executable shell script.
+func writeProgram(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go
new file mode 100644
index 0000000000..c6f0566d5f
--- /dev/null
+++ b/internal/delegate/protocol.go
@@ -0,0 +1,404 @@
+package delegate
+
+// The records: one JSON object per line on the program's stdout, the types
+// below read, everything else ignored (docs/design/delegate/PROTOCOL.md).
+// Ignoring the rest is what makes the reader generic — a program's own records
+// pass straight through — and it is also why a line that is not JSON at all is
+// dropped and counted rather than failing the run: a program that printed one
+// stray line has not stopped being one codeaf can run.
+//
+// THERE IS NO SPEND RECORD. Version 1 read a cumulative `spend` the program
+// reported about itself; the model API (internal/provider/modelapi) meters
+// every call the program makes as it is made, so money has one source of truth
+// and it is not the program's word. A `spend` line a program still writes is
+// one more line this reader does not know, ignored and counted like any other.
+//
+// VERSION 2 IS INTERNAL. Both ends are compiled from this package into one
+// binary, so the Go types here are the specification and the number in `hello`
+// guards the one case where the two ends can still differ: an engine that
+// outlived a rebuild starting the NEW binary as its child.
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+)
+
+// The record types.
+const (
+ // RecordHello is the first line a program writes: the protocol it speaks,
+ // its name, and the stages it will move through, in order.
+ RecordHello = "hello"
+ RecordStage = "stage"
+ RecordStep = "step"
+ RecordTerminal = "terminal"
+)
+
+// ProtocolVersion is the version `hello` carries. Both ends are this package,
+// so it moves only when a record changes meaning, and a mismatch means the two
+// processes are two builds.
+//
+// AN OPTIONAL FIELD ADDED TO A RECORD IS NOT A NEW MEANING. A stage's `data`
+// and a step's `tool`, `step` and `exit` arrived inside version 2: a reader
+// that predates them ignores them as it ignores every field it does not know,
+// and a program that does not send them is read exactly as before.
+const ProtocolVersion = 2
+
+// Hello is the first record: who is speaking, in which protocol, and the
+// stages it will move through, which is what lets a page draw the whole track
+// before the program has reached the end of it.
+type Hello struct {
+ Protocol int `json:"protocol"`
+ Delegate string `json:"delegate"`
+ Stages []string `json:"stages,omitempty"`
+}
+
+// The terminal statuses. The set is closed and it is senior-dev's, because
+// senior-dev's projection of an ending onto four words was already the right one:
+// the work stands, it does not, a ceiling stopped it, or the program itself
+// broke.
+const (
+ StatusPass = "pass"
+ StatusFail = "fail"
+ StatusBudget = "budget-exhausted"
+ StatusCrashed = "crashed"
+)
+
+// Caps the reader applies so a record can never carry more than the page
+// draws. A program that sends more is cut here, on a rune boundary, rather
+// than trusted to have capped itself.
+const (
+ commandCap = 200
+ observationCap = 2048
+ // labelCap bounds a step's tool name and its step id: each is one word a
+ // page prints, never a payload.
+ labelCap = 64
+)
+
+// StageDataCap is the most bytes a stage record's data may take, in JSON. It
+// is a curated copy of what the program already knows about the phase — an
+// attempt number, a count, a verdict of its own checks — for a page to say in
+// words, and never the program's whole account of itself, which stays on its
+// stderr. A reader drops data past it rather than cut it, because half an
+// object is not an object; the program is expected to have curated to it, and
+// senior-dev does (internal/seniordev/app's stage_data.go).
+const StageDataCap = 1024
+
+// StageRecord is one `stage` record: the phase the program moved to, how it
+// stands in it, and the small copy of what it knows about it.
+type StageRecord struct {
+ Stage string `json:"stage"`
+ Status string `json:"status"`
+ // Data is a JSON object of at most [StageDataCap] bytes, or nothing. It is
+ // OPTIONAL AND ADDITIVE: a reader of version 2 that predates it reads the
+ // record without it.
+ Data json.RawMessage `json:"data,omitempty"`
+}
+
+// StepRecord is one `step` record: one finished action, what was run and the
+// head of what came back, and — each optional, each absent from a program that
+// does not say it — the tool that ran it, the step of the program's own
+// process it served, and a command's exit code.
+type StepRecord struct {
+ // Command is the action on one line, `: `.
+ Command string `json:"command"`
+ // Observation is the head of what came back.
+ Observation string `json:"observation,omitempty"`
+ // Tool is the tool's own name.
+ Tool string `json:"tool,omitempty"`
+ // Step is the program's own id for the part of its process the action
+ // served (senior-dev's are app.Steps). It is the program's word, drawn
+ // through the program's own vocabulary ([Delegate.Present]).
+ Step string `json:"step,omitempty"`
+ // Exit is a command's exit code, present only for an action that ran a
+ // command and learned how it exited — which is why it is a pointer: a
+ // command that exited 0 and an action that ran none are two facts.
+ Exit *int `json:"exit,omitempty"`
+ // Added and Removed are the lines an action that changed a file added and
+ // removed, present only when the program counted them.
+ Added *int `json:"added,omitempty"`
+ Removed *int `json:"removed,omitempty"`
+}
+
+// stageData is a record's data as a reader keeps it: a JSON object of at most
+// [StageDataCap] bytes, and nothing for anything else.
+func stageData(raw json.RawMessage) json.RawMessage {
+ trimmed := strings.TrimSpace(string(raw))
+ if len(trimmed) > StageDataCap || !strings.HasPrefix(trimmed, "{") || !json.Valid([]byte(trimmed)) {
+ return nil
+ }
+ return json.RawMessage(trimmed)
+}
+
+// label is a step's tool name or step id as a reader keeps it: one line, cut.
+func label(s string) string { return cut(oneLine(s), labelCap) }
+
+// maxLineBytes bounds one stdout line. A program that writes a megabyte on one
+// line is mirroring something it should not, and a reader without a bound is
+// a way for a child to take the parent's memory.
+const maxLineBytes = 4 << 20
+
+// Terminal is the one record that is the result. Data is kept whole so the
+// landing note can read the optional keys, in the protocol's spelling and in
+// senior-dev's own, through the accessors below rather than by every caller
+// knowing both.
+type Terminal struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Data map[string]json.RawMessage `json:"data"`
+}
+
+// CostUSD is the final total, and false when the record did not carry one.
+func (t Terminal) CostUSD() (float64, bool) { return t.number("cost_usd") }
+
+// Reason is the longer reason when there is one.
+func (t Terminal) Reason() string { return t.text("reason") }
+
+// Claim is what the program's model said it did: `claim` in the protocol,
+// `submission_reason` in senior-dev's record.
+func (t Terminal) Claim() string { return first(t.text("claim"), t.text("submission_reason")) }
+
+// Observed is what the program itself verified: `observed` in the protocol.
+// senior-dev spells its observation as its own inner status and a count of
+// failing verification commands, which read here as one sentence so the
+// landing note can keep the claim and the observation apart.
+func (t Terminal) Observed() string {
+ if observed := t.text("observed"); observed != "" {
+ return observed
+ }
+ inner := t.text("status")
+ if inner == "" {
+ return ""
+ }
+ if failing, ok := t.number("verification_failing"); ok && failing > 0 {
+ commands, _ := t.number("verification_commands")
+ return inner + ", verification failed " + strconv.Itoa(int(failing)) + " of " + strconv.Itoa(int(commands)) + " commands"
+ }
+ return inner
+}
+
+// Verdict is the program's own word for how its work stood when it ended —
+// senior-dev's inner status (`pass`, `pass-unverified`, `fail`) — beside the
+// protocol's status word, and "" when the record carried none.
+func (t Terminal) Verdict() string { return t.text("status") }
+
+// Deliverable is the answer text of a delegate that lands text.
+func (t Terminal) Deliverable() string { return t.text("deliverable") }
+
+func (t Terminal) text(key string) string {
+ raw, ok := t.Data[key]
+ if !ok {
+ return ""
+ }
+ var s string
+ if json.Unmarshal(raw, &s) != nil {
+ return ""
+ }
+ return strings.TrimSpace(s)
+}
+
+func (t Terminal) number(key string) (float64, bool) {
+ raw, ok := t.Data[key]
+ if !ok {
+ return 0, false
+ }
+ var n float64
+ if json.Unmarshal(raw, &n) != nil {
+ return 0, false
+ }
+ return n, true
+}
+
+func first(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// KnownStatus answers whether a terminal status is one of the four.
+func KnownStatus(status string) bool {
+ switch status {
+ case StatusPass, StatusFail, StatusBudget, StatusCrashed:
+ return true
+ }
+ return false
+}
+
+// Sink is what a reader tells as the stream arrives. Every method is called on
+// the reader's goroutine, in stream order, and none may block on the program:
+// a sink that waits on the child is a deadlock with a pipe in the middle.
+type Sink interface {
+ // Hello is the program's first record, told once.
+ Hello(h Hello)
+ // Stage is a phase change: the live step, and one line of the program's
+ // action log. Its data is already held to [StageDataCap].
+ Stage(record StageRecord)
+ // Step is one finished action: command and the observation head, both
+ // already capped, and the tool, step and exit the program said.
+ Step(record StepRecord)
+ // Terminal is the result. It is told at most once; a second terminal on
+ // the stream is ignored, because the contract says exactly one and the
+ // first is the one the program wrote on purpose.
+ Terminal(t Terminal)
+}
+
+// Reading is what a reader saw, for the record the launch keeps: the last
+// stage, how many steps, whether a terminal arrived, and how many lines were
+// not the protocol's (dropped, not failed). What the run spent is not here:
+// the model API metered it call by call, and a reading of the program's
+// stdout is not where money is learned.
+type Reading struct {
+ Hello *Hello
+ LastStage string
+ LastStatus string
+ Steps int
+ Terminal *Terminal
+ Ignored int
+}
+
+// Read consumes r to its end, telling sink each record, and answers what it
+// saw. It returns when the stream closes, which for a pipe is when the program
+// exits or closes stdout; an error is only a read failure on the stream itself.
+func Read(r io.Reader, sink Sink) (Reading, error) {
+ var reading Reading
+ scanner := bufio.NewScanner(r)
+ scanner.Buffer(make([]byte, 64<<10), maxLineBytes)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ var head struct {
+ Type string `json:"type"`
+ }
+ if !strings.HasPrefix(line, "{") || json.Unmarshal([]byte(line), &head) != nil {
+ reading.Ignored++
+ continue
+ }
+ switch head.Type {
+ case RecordHello:
+ // ONE HELLO. A second is ignored for the reason a second terminal
+ // is: the first is the one the program wrote on purpose.
+ if reading.Hello != nil {
+ reading.Ignored++
+ continue
+ }
+ var rec Hello
+ if json.Unmarshal([]byte(line), &rec) != nil {
+ reading.Ignored++
+ continue
+ }
+ reading.Hello = &rec
+ if sink != nil {
+ sink.Hello(rec)
+ }
+ case RecordStage:
+ // THE OPTIONAL FIELDS ARE READ FORGIVINGLY. A stage whose data is
+ // not an object, or is past the cap, is still the stage: the data
+ // is left off, never the record.
+ var rec struct {
+ Stage string `json:"stage"`
+ Status string `json:"status"`
+ Data json.RawMessage `json:"data"`
+ }
+ if json.Unmarshal([]byte(line), &rec) != nil || rec.Stage == "" {
+ reading.Ignored++
+ continue
+ }
+ reading.LastStage, reading.LastStatus = rec.Stage, rec.Status
+ if sink != nil {
+ sink.Stage(StageRecord{Stage: rec.Stage, Status: rec.Status, Data: stageData(rec.Data)})
+ }
+ case RecordStep:
+ // And so are a step's: a tool, a step id or an exit of another
+ // shape than this reader's is left off, because a program that
+ // spelled an optional field its own way has still finished the
+ // action it is reporting.
+ var rec struct {
+ Command string `json:"command"`
+ Observation string `json:"observation"`
+ Tool json.RawMessage `json:"tool"`
+ Step json.RawMessage `json:"step"`
+ Exit json.RawMessage `json:"exit"`
+ Added json.RawMessage `json:"added"`
+ Removed json.RawMessage `json:"removed"`
+ }
+ if json.Unmarshal([]byte(line), &rec) != nil || strings.TrimSpace(rec.Command) == "" {
+ reading.Ignored++
+ continue
+ }
+ reading.Steps++
+ if sink != nil {
+ sink.Step(StepRecord{
+ Command: cut(oneLine(rec.Command), commandCap),
+ Observation: cut(rec.Observation, observationCap),
+ Tool: label(rawText(rec.Tool)),
+ Step: label(rawText(rec.Step)),
+ Exit: rawWhole(rec.Exit),
+ Added: rawWhole(rec.Added),
+ Removed: rawWhole(rec.Removed),
+ })
+ }
+ case RecordTerminal:
+ if reading.Terminal != nil {
+ reading.Ignored++
+ continue
+ }
+ var rec Terminal
+ if json.Unmarshal([]byte(line), &rec) != nil || rec.Status == "" {
+ reading.Ignored++
+ continue
+ }
+ reading.Terminal = &rec
+ if sink != nil {
+ sink.Terminal(rec)
+ }
+ default:
+ reading.Ignored++
+ }
+ }
+ return reading, scanner.Err()
+}
+
+// rawText is an optional field read as a string, and nothing when it is
+// absent or of another shape.
+func rawText(raw json.RawMessage) string {
+ var s string
+ if len(raw) == 0 || json.Unmarshal(raw, &s) != nil {
+ return ""
+ }
+ return s
+}
+
+// rawWhole is an optional field read as a whole number, and nil when it is
+// absent or of another shape.
+func rawWhole(raw json.RawMessage) *int {
+ var n int
+ if len(raw) == 0 || json.Unmarshal(raw, &n) != nil {
+ return nil
+ }
+ return &n
+}
+
+// oneLine folds a command onto one line, because it is drawn in a row.
+func oneLine(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+}
+
+// cut caps text at n bytes on a rune boundary, so a record never opens a
+// character it does not close.
+func cut(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ for n > 0 && !utf8.RuneStart(s[n]) {
+ n--
+ }
+ return s[:n]
+}
diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go
new file mode 100644
index 0000000000..385c114899
--- /dev/null
+++ b/internal/delegate/protocol_test.go
@@ -0,0 +1,279 @@
+package delegate
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// recorder is a Sink that keeps what it was told, in order. It is read after
+// the reader is done, except for spoke, which a launch test waits on to know
+// the program has said its first word.
+type recorder struct {
+ mu sync.Mutex
+ once sync.Once
+ spoke chan struct{}
+ hello *Hello
+ stages []string
+ steps []string
+ // stageRecords and stepRecords are the records whole, for the tests of the
+ // optional fields.
+ stageRecords []StageRecord
+ stepRecords []StepRecord
+ terminal *Terminal
+}
+
+func newRecorder() *recorder { return &recorder{spoke: make(chan struct{})} }
+
+func (r *recorder) Hello(h Hello) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.hello = &h
+}
+
+func (r *recorder) Stage(stage StageRecord) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.stages = append(r.stages, stage.Stage+"·"+stage.Status)
+ r.stageRecords = append(r.stageRecords, stage)
+ if r.spoke != nil {
+ r.once.Do(func() { close(r.spoke) })
+ }
+}
+func (r *recorder) Step(step StepRecord) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.steps = append(r.steps, step.Command+"→"+step.Observation)
+ r.stepRecords = append(r.stepRecords, step)
+}
+func (r *recorder) Terminal(t Terminal) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.terminal = &t
+}
+
+// A recorded senior-dev stream, taken from EVENTS-CONTRACT.md's shapes, read
+// through the one generic reader: the stages reach the live step, the steps
+// reach the page, the terminal is the result, and every bus payload passes
+// through untouched. The stream was recorded while the program still reported
+// its own `spend`; those lines are read now as what they are — lines this
+// reader does not know — because the model API meters money itself.
+func TestTheReaderReplaysASeniorDevStream(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("testdata", "senior-dev-stream.ndjson"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sink := &recorder{}
+ reading, err := Read(strings.NewReader(string(data)), sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reading.Terminal == nil || reading.Terminal.Status != StatusPass {
+ t.Fatalf("terminal = %+v, want the pass senior-dev wrote last", reading.Terminal)
+ }
+ if reading.LastStage != "agent-summary" {
+ t.Fatalf("last stage = %q, want agent-summary, the stage before the terminal", reading.LastStage)
+ }
+ if reading.Steps != 2 {
+ t.Fatalf("steps %d, want 2", reading.Steps)
+ }
+ // Three bus payloads and three v1 spend lines are on the stream; all six
+ // are ignored, not failed.
+ if reading.Ignored != 6 {
+ t.Fatalf("ignored = %d, want the three bus payloads and the three spend lines", reading.Ignored)
+ }
+ if got := strings.Join(sink.stages, " "); !strings.Contains(got, "implement·running") || !strings.Contains(got, "verification·pass") {
+ t.Fatalf("stages = %q", got)
+ }
+ if sink.steps[0] != "bash: go test ./...→ok \tpkg\t0.3s" || sink.steps[1] != "edit: internal/auth/middleware.go→" {
+ t.Fatalf("steps told = %q", sink.steps)
+ }
+ // The terminal's optional keys read in senior-dev's spelling. Its cost is
+ // the program's own reading, kept on the record and never banked.
+ cost, ok := sink.terminal.CostUSD()
+ if !ok || cost != 0.0213 {
+ t.Fatalf("terminal cost = %v %v", cost, ok)
+ }
+ if sink.terminal.Claim() != "tests pass" {
+ t.Fatalf("claim = %q, want senior-dev's submission_reason", sink.terminal.Claim())
+ }
+ if sink.terminal.Observed() != "pass" {
+ t.Fatalf("observed = %q, want senior-dev's own inner status", sink.terminal.Observed())
+ }
+}
+
+// ONE TERMINAL, AND NO WORD OF THE PROGRAM'S ABOUT MONEY. A second terminal
+// is dropped, and a v1 `spend` record is a line this reader does not know: the
+// model API is where a run's money is metered, so nothing the program says
+// about its own spending reaches a sink.
+func TestTheReaderTakesOneTerminalAndNoSpendRecord(t *testing.T) {
+ stream := strings.Join([]string{
+ `{"type":"spend","cost_usd":0.5}`,
+ `{"type":"spend","cost_usd":0.2}`,
+ `{"type":"terminal","status":"fail","message":"first"}`,
+ `{"type":"terminal","status":"pass","message":"second"}`,
+ `not json at all`,
+ `{"type":"something-else"}`,
+ ``,
+ }, "\n")
+ sink := &recorder{}
+ reading, err := Read(strings.NewReader(stream), sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sink.terminal == nil || sink.terminal.Message != "first" {
+ t.Fatalf("terminal = %+v, want the first one only", sink.terminal)
+ }
+ // The two spend lines, the second terminal, the stray line and the unknown
+ // type are the five ignored lines; the empty line is nothing.
+ if reading.Ignored != 5 {
+ t.Fatalf("ignored = %d, want the two spend lines, the second terminal, the stray line and the unknown type", reading.Ignored)
+ }
+}
+
+func TestTheReaderCapsAStepOnARuneBoundary(t *testing.T) {
+ long := strings.Repeat("é", 2000)
+ stream := `{"type":"step","command":" bash: two words ","observation":"` + long + `"}` + "\n"
+ sink := &recorder{}
+ if _, err := Read(strings.NewReader(stream), sink); err != nil {
+ t.Fatal(err)
+ }
+ got := sink.steps[0]
+ command, observation, _ := strings.Cut(got, "→")
+ if command != "bash: two words" {
+ t.Fatalf("command = %q, want it folded onto one line", command)
+ }
+ if len(observation) > observationCap || !strings.HasSuffix(observation, "é") {
+ t.Fatalf("observation is %d bytes ending %q, want ≤ %d on a rune boundary", len(observation), observation[len(observation)-2:], observationCap)
+ }
+}
+
+func TestObservedReadsSeniorDevsVerificationCount(t *testing.T) {
+ sink := &recorder{}
+ stream := `{"type":"terminal","status":"fail","message":"x","data":{"status":"fail","verification_failing":2,"verification_commands":5}}`
+ if _, err := Read(strings.NewReader(stream), sink); err != nil {
+ t.Fatal(err)
+ }
+ if got := sink.terminal.Observed(); got != "fail, verification failed 2 of 5 commands" {
+ t.Fatalf("observed = %q", got)
+ }
+}
+
+// THE FIRST HELLO IS THE ONE READ: it carries the protocol, the name and the
+// stages, and a second is ignored for the reason a second terminal is.
+func TestTheReaderTakesOneHelloWithItsStages(t *testing.T) {
+ stream := strings.Join([]string{
+ `{"type":"hello","protocol":2,"delegate":"senior-dev","stages":["bootstrap","implement","submit"]}`,
+ `{"type":"hello","protocol":9,"delegate":"other"}`,
+ `{"type":"stage","stage":"implement","status":"running"}`,
+ }, "\n")
+ sink := &recorder{}
+ reading, err := Read(strings.NewReader(stream), sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reading.Hello == nil || reading.Hello.Protocol != ProtocolVersion || reading.Hello.Delegate != "senior-dev" {
+ t.Fatalf("hello = %+v, want the first one", reading.Hello)
+ }
+ if sink.hello == nil || strings.Join(sink.hello.Stages, ",") != "bootstrap,implement,submit" {
+ t.Fatalf("hello told = %+v", sink.hello)
+ }
+ if reading.Ignored != 1 {
+ t.Fatalf("ignored = %d, want the second hello", reading.Ignored)
+ }
+}
+
+// A STEP SAYS ITS TOOL, ITS STEP AND A COMMAND'S EXIT, AND A STAGE ITS DATA —
+// each optional, each read forgivingly. A field of another shape than this
+// reader's is left off and the record kept; data that is not an object, or is
+// past the cap, is left off the stage and the stage kept; and a record that
+// carries none of them reads exactly as it did before they existed.
+func TestTheReaderCarriesTheOptionalFieldsAndForgivesTheirShape(t *testing.T) {
+ big := `{"text":"` + strings.Repeat("x", StageDataCap) + `"}`
+ stream := strings.Join([]string{
+ `{"type":"step","command":"bash: go test ./...","observation":"FAIL","tool":"bash","step":"explore","exit":1}`,
+ `{"type":"step","command":"bash: go build ./...","tool":"bash","step":"verify","exit":0}`,
+ `{"type":"step","command":"read: a.go","tool":7,"step":{"id":"x"},"exit":"one"}`,
+ `{"type":"step","command":"edit: a.go"}`,
+ `{"type":"stage","stage":"submit","status":"frozen","data":{"patch_files":4,"checklist_items":5}}`,
+ `{"type":"stage","stage":"verification","status":"pass","data":[1,2]}`,
+ `{"type":"stage","stage":"verification","status":"pass","data":` + big + `}`,
+ `{"type":"stage","stage":"bootstrap","status":"ready"}`,
+ }, "\n")
+ sink := &recorder{}
+ reading, err := Read(strings.NewReader(stream), sink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reading.Steps != 4 || len(sink.stageRecords) != 4 || reading.Ignored != 0 {
+ t.Fatalf("steps %d, stages %d, ignored %d; want every record kept", reading.Steps, len(sink.stageRecords), reading.Ignored)
+ }
+ first := sink.stepRecords[0]
+ if first.Tool != "bash" || first.Step != "explore" || first.Exit == nil || *first.Exit != 1 {
+ t.Fatalf("first step = %+v, want its tool, its step and its exit", first)
+ }
+ // AN EXIT OF 0 IS A FACT, NOT AN ABSENCE.
+ if second := sink.stepRecords[1]; second.Exit == nil || *second.Exit != 0 {
+ t.Fatalf("second step = %+v, want exit 0 kept", second)
+ }
+ if odd := sink.stepRecords[2]; odd.Tool != "" || odd.Step != "" || odd.Exit != nil || odd.Command != "read: a.go" {
+ t.Fatalf("a step with odd-shaped optional fields = %+v, want them left off and the step kept", odd)
+ }
+ if plain := sink.stepRecords[3]; plain.Tool != "" || plain.Step != "" || plain.Exit != nil {
+ t.Fatalf("a step with no optional fields = %+v", plain)
+ }
+ if got := string(sink.stageRecords[0].Data); got != `{"patch_files":4,"checklist_items":5}` {
+ t.Fatalf("stage data = %s, want the object as written", got)
+ }
+ for i := 1; i <= 3; i++ {
+ if data := sink.stageRecords[i].Data; data != nil {
+ t.Fatalf("stage %d data = %s, want none: not an object, past the cap, or never sent", i, data)
+ }
+ }
+}
+
+// A STEP THAT CHANGED A FILE CARRIES ITS LINES, added and removed, and a zero
+// is a count like any other; an odd shape is left off and the step kept.
+func TestTheReaderCarriesAStepsLinesAddedAndRemoved(t *testing.T) {
+ stream := strings.Join([]string{
+ `{"type":"step","command":"edit: a.go","tool":"edit","added":12,"removed":0}`,
+ `{"type":"step","command":"write: b.go","tool":"write","added":"many"}`,
+ }, "\n")
+ sink := &recorder{}
+ if _, err := Read(strings.NewReader(stream), sink); err != nil {
+ t.Fatal(err)
+ }
+ first := sink.stepRecords[0]
+ if first.Added == nil || *first.Added != 12 || first.Removed == nil || *first.Removed != 0 {
+ t.Fatalf("an edit's lines = %+v, want +12 and a kept zero", first)
+ }
+ action := StepAction(time.Time{}, first)
+ if action.Added == nil || *action.Added != 12 || action.Removed == nil || *action.Removed != 0 {
+ t.Fatalf("the action log's line = %+v, want the step's lines", action)
+ }
+ if odd := sink.stepRecords[1]; odd.Added != nil || odd.Removed != nil {
+ t.Fatalf("an odd-shaped count = %+v, want it left off", odd)
+ }
+}
+
+// AND THE LINES CROSS THE WIRE: what a program's emitter writes for a step's
+// lines is what codeaf's reader takes back, a zero included. The emitter wrote
+// a fixed list of a step's fields, and a count it did not name never left the
+// program.
+func TestAStepsLinesSurviveTheEmitterAndTheReader(t *testing.T) {
+ var wire strings.Builder
+ added, removed := 7, 0
+ if err := NewEmitter(&wire).Step(StepRecord{Command: "write: a.go", Tool: "write", Added: &added, Removed: &removed}); err != nil {
+ t.Fatal(err)
+ }
+ sink := &recorder{}
+ if _, err := Read(strings.NewReader(wire.String()), sink); err != nil {
+ t.Fatal(err)
+ }
+ if got := sink.stepRecords[0]; got.Added == nil || *got.Added != 7 || got.Removed == nil || *got.Removed != 0 {
+ t.Fatalf("the step read back = %+v from %q, want +7,-0", got, wire.String())
+ }
+}
diff --git a/internal/delegate/testdata/senior-dev-stream.ndjson b/internal/delegate/testdata/senior-dev-stream.ndjson
new file mode 100644
index 0000000000..0511542170
--- /dev/null
+++ b/internal/delegate/testdata/senior-dev-stream.ndjson
@@ -0,0 +1,19 @@
+{"type":"stage","stage":"bootstrap","status":"ready","data":{"workspace":"/tmp/copy"},"ts":1725000000000,"trace_id":"ses_1","step":1,"occurrence":1,"title":"Bootstrap: Ready","elapsed_ms":3}
+{"type":"stage","stage":"run-contract","status":"ready","data":{"base_sha":"abc","high_models":["openrouter/deepseek/deepseek-v4-flash-0731"],"entry_agent":"coder","control_plane":{"enabled":false,"url":"http://localhost:8080"}},"ts":1725000000010}
+{"id":"evt_1","type":"session.created","properties":{"sessionID":"ses_1","info":{"id":"ses_1","title":"rewrite the auth middleware"}}}
+{"type":"stage","stage":"intake","status":"captured","data":{"spec_path":".senior-dev/spec.md","spec_bytes":42},"ts":1725000000020}
+{"type":"stage","stage":"agent-runtime","status":"configured","data":{"agent":"coder","session_id":"ses_1","model_id":"deepseek-v4-flash-0731"},"ts":1725000000030}
+{"type":"stage","stage":"implement","status":"running","data":{"attempt":0},"ts":1725000000040}
+{"id":"evt_2","type":"message.updated","properties":{"sessionID":"ses_1","info":{"role":"assistant","id":"msg_1","cost":0.0101,"tokens":{"input":100,"output":20}}}}
+{"type":"spend","cost_usd":0.0101,"ts":1725000000100}
+{"id":"evt_3","type":"message.part.updated","properties":{"sessionID":"ses_1","part":{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"go test ./..."},"output":"ok \tpkg\t0.3s"}},"time":1725000000110}}
+{"type":"step","command":"bash: go test ./...","observation":"ok \tpkg\t0.3s","ts":1725000000110}
+{"type":"step","command":"edit: internal/auth/middleware.go","observation":"","ts":1725000000120}
+{"type":"spend","cost_usd":0.0213,"ts":1725000000200}
+{"type":"spend","cost_usd":0.0213,"ts":1725000000201}
+{"type":"stage","stage":"submit","status":"frozen","data":{"reason":"tests pass","checklist_satisfied":true,"patch_bytes":812,"patch_files":2,"tree_sha":"t1","commit_sha":"c1"},"ts":1725000000300}
+{"type":"stage","stage":"implement","status":"submitted","data":{"attempt":0,"reason":"tests pass","checklist_satisfied":true},"ts":1725000000301}
+{"type":"stage","stage":"verification","status":"pass","data":{"commands":[{"cmd":"go test ./...","exit":0}],"vacuous":false},"ts":1725000000400}
+{"type":"stage","stage":"patch-summary","status":"completed","data":{"base_sha":"abc","files":2,"additions":30,"deletions":4,"patch_bytes":812},"ts":1725000000410}
+{"type":"stage","stage":"agent-summary","status":"completed","data":{"agents":{"coder":{"calls":3,"cost_usd":0.0213}}},"ts":1725000000420}
+{"type":"terminal","status":"pass","message":"submitted and verified: tests pass","session_id":"ses_1","data":{"cost_usd":0.0213,"status":"pass","reason":"submitted and verified","submitted":true,"nudges":0,"submission_reason":"tests pass","submission_evidence":"go test ./... is green","checklist_satisfied":true,"patch_bytes":812,"patch_files":2,"frozen_tree":"t1","frozen_commit":"c1","verification_failing":0,"verification_commands":1},"ts":1725000000500}
diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go
index d69bb477df..1e716e0f1b 100644
--- a/internal/enginehost/enginehost.go
+++ b/internal/enginehost/enginehost.go
@@ -65,13 +65,19 @@ const (
// SocketLimit is the most bytes a unix socket path may weigh.
//
-// It is 104 rather than Linux's own 108 because THE SMALLEST LIMIT IS THE ONE
-// THAT TRAVELS: macOS stops at 104, the same codeaf home can be shared over a
-// network mount, and a host that worked on one machine and refused on another
-// for a reason nobody could see would be worse than one honest refusal
-// everywhere. Exceeding it is not a fault — CODEAF_HOME can be anywhere — so it
-// is answered as "no host today" and the caller falls back to the pipe.
-const SocketLimit = 104
+// It is macOS's rather than Linux's because THE SMALLEST LIMIT IS THE ONE THAT
+// TRAVELS: the same codeaf home can be shared over a network mount, and a host
+// that worked on one machine and refused on another for a reason nobody could
+// see would be worse than one honest refusal everywhere. Exceeding it is not a
+// fault — CODEAF_HOME can be anywhere — so it is answered as "no host today"
+// and the caller falls back to the pipe.
+//
+// IT IS 103, NOT 104. macOS's sun_path is 104 bytes and the NUL that ends the
+// name takes one of them. At 104 this answered "fits" for a path bind() then
+// refused with "invalid argument": a state root in $TMPDIR whose socket path
+// came to exactly 104 bytes started no host, took no fallback, and codeaf did
+// not open at all.
+const SocketLimit = 103
// ErrSocketPathTooLong is a state root deeper than a unix socket may be named
// in, and it is the one failure on this road that is settled BEFORE anything
diff --git a/internal/enginehost/enginehost_test.go b/internal/enginehost/enginehost_test.go
index 838a3ed377..f7cccc02cb 100644
--- a/internal/enginehost/enginehost_test.go
+++ b/internal/enginehost/enginehost_test.go
@@ -675,6 +675,19 @@ func TestHostedAgentReadsSeededPlanTasksEndToEnd(t *testing.T) {
t.Fatalf("PlanTasks over host = %+v, want seeded real-store row", rows)
}
+// A path of exactly 104 bytes does not fit: macOS's socket name holds 104
+// bytes and its terminating NUL is one of them, so bind refuses it with
+// "invalid argument". Answering "fits" there left codeaf with no host and no
+// fallback, and it did not open.
+func TestASocketPathFitsOnlyWithRoomForItsEnd(t *testing.T) {
+ if !SocketPathFits(strings.Repeat("a", 103)) {
+ t.Fatal("a 103-byte path was refused; it binds on every platform")
+ }
+ if SocketPathFits(strings.Repeat("a", 104)) {
+ t.Fatal("a 104-byte path was said to fit; macOS refuses to bind it")
+ }
+}
+
// THE TASK PAGE'S MODEL IS THE LEDGER'S, and it has to cross the host: the
// room head reads it off the page the engine served, and a page that arrives
// without it draws the price and the tokens and never the model.
diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md
index c7188f5ace..5b16e3aa61 100644
--- a/internal/manual/chat/commands.md
+++ b/internal/manual/chat/commands.md
@@ -178,6 +178,7 @@ Canonical word, the other words it answers to, its argument form, and what it do
| `/harness` | `/harnesses` | — | lists the saved shapes of work and what they did |
| `/subharness` | `/sub` | — | lists the programs you can run; type to filter, enter opens that one's card |
| `/subharness` | `/sub` | `` | opens that subharness's intake card straight away |
+| `/` | — | `` | one row per program this build carries: starts a task that program does on its own |
| `/skill` | `/skills` | — | opens the skill shelf under the message box; enter toggles a skill, and its chip stays attached across messages |
| `/memory` | — | — | opens the memory panel |
| `/memory` | `/memories` | `` | prints matching memories into the conversation |
@@ -1262,6 +1263,17 @@ launch on this machine and `--no-host` both wire this machine's registry and ope
panel. The second is drawn as the panel's only row, and it is also what a registry that
cannot be read at all shows, rather than an error.
+## / — a program codeaf carries, handed a whole task
+
+Every program your build carries is a command of its own: `/ ` hands the brief
+to that program and starts a task at once, exactly as `/task ` does with codeaf's own
+worker. The rows come from the build itself, so there is nothing to install and a build that
+carries no program has no such row. With no brief it says its usage:
+`usage: / · hands the whole task to that program`.
+
+Over `--host` the rows are the far machine's build's, and a row you run starts the work there.
+The *Programs codeaf carries* page says what one is, what it cannot do, and where its work goes.
+
## /subharness — the command's two forms, bare and with a name after it
`/subharness` (or `/sub`) opens a filtering list of the programs this conversation can
diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md
new file mode 100644
index 0000000000..f2ba7bdc3f
--- /dev/null
+++ b/internal/manual/chat/delegates.md
@@ -0,0 +1,225 @@
+# Programs codeaf carries
+
+## What a program codeaf carries is — a delegate, another coding agent, an agent of its own for a whole task
+
+codeaf carries programs of its own that take one whole coding task and do it alone, for as
+long as an hour or more. People call them delegates. You hand one a task the way codeaf
+hands a task to its own worker: it works in your folder itself, under this
+conversation's dollar and time limits, shows on the rail while it runs, can be stopped,
+and in a git repository leaves its work on a branch of its own, checked out, when it
+ends.
+
+Each one is **built into codeaf**. There is nothing to install and nothing to set up, and
+none of them runs on its own outside codeaf. Each is a command in the chat, `/
+`, and a verb at a shell, `codeaf `. The verbs are listed in
+`codeaf --help`.
+
+**It reaches a model only through codeaf.** codeaf serves each run its own model API.
+Your key stays in codeaf and never reaches the program or any command it runs. Every
+call the program makes goes through codeaf's own model road, so it is priced into your
+spending, held to the run's dollar ceiling, and kept as one turn of its conversation with
+its model. The run's task page, which opens inside the conversation's own tab like any
+task's, shows the actions the program took, each under the step of its own process, and
+`ctrl+y` turns it to those raw calls. When your services cannot serve the model the
+program asks for, the run's own work model answers, and the raw calls name the model that
+did.
+
+**Every program's tasks wear its name as a badge**: `[]` after the task's title on
+the side list, the card, the task's page, the `@` list, the tasks place and home, and its
+initials (`[sd]` for senior-dev) where a list is narrow. A task codeaf's own worker does
+wears none, and a program added to codeaf later gets its own badge from its name.
+
+This is different from a harness or a subharness, which are built out of codeaf's own
+parts. A program codeaf carries has an engine of its own.
+
+## How do I hand work to it — / , codeaf in a shell, via, delegate this to another agent
+
+Type its name as a command, then the brief:
+
+```
+/ rewrite the auth middleware to use the new session store
+```
+
+That is `/task` with the worker chosen. A run starts at once in your folder, the turn goes
+on, and the row appears on the rail.
+
+The model can choose one as well, and reaches for one by itself (see *When codeaf hands
+work to a program by itself*). `propose_task` takes `via` naming the program, and the
+card you answer says which program the work is going to: it asks `wants to start a
+[] task: `, and its top line wears the program's badge. The model is told
+the programs your build carries, each in the program's own words: what it is for, what its
+brief must say, and what it needs of its folder.
+
+At a shell, `codeaf ` runs the same program in the folder you are in, or the
+one `--dir` names. `--max-cost` and `--max-hours` set its ceilings, and `--json` prints its
+records instead of readable lines. `codeaf --help` lists its own commands and flags.
+Its last line says what the run came to, such as `277 model calls · $2.30 · 22m 51s`: the
+calls, the dollars and how long the program ran.
+
+## When codeaf hands work to a program by itself — will it use one without being asked, naming one is enough, it did the work itself instead
+
+The chat's model is told to hand the work a program is for to that program, whole, rather
+than doing it itself or giving it to codeaf's own worker, even when it is one long job
+with nothing to run beside it. Each program's own line says what it is for: senior-dev's
+claims complex, multi-part coding work, such as fixing an issue in a mature codebase
+whose cause spans files, a feature with its tests, a rewrite across a package, or a
+migration. The model proposes that work with `via` naming the program, and its card goes
+up like any proposal's.
+
+**Naming the program is enough.** Say it in your message, by name or as its command
+("fix issue 412 with senior-dev", "give this to /senior-dev", "senior dev should do
+this"), and the model is told to use it. If it proposes the work without the program
+anyway, codeaf turns that proposal back once, along with every other proposal without
+it in the same reply:
+``the person named senior-dev: if they want it to do this work, propose this again with `via: "senior-dev"`; if they asked for it not to be used, or did not mean the program, propose it again unchanged``.
+A proposal the model makes after reading that passes as it is, so "don't use senior-dev
+for this" is kept too. The next section says what else counts.
+
+**What it does not do.** A reply codeaf moves to a task on its own, because it ran long or
+looked like work, goes to codeaf's own worker and never to a program. A task never hands
+its work to a program. `/ ` starts the program at once, with no card.
+
+## Naming a program in a small ask or a correction — fix this file with senior-dev, revert what senior-dev did, I typed a correction and it forgot senior-dev
+
+**An ask for a program is never too small.** A one-file fix or a single read otherwise
+stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. For
+that, your words have to ask for the program: its command (`/senior-dev`), its name
+first in the message, or its name right after with, via, using, use, give, hand, to,
+have, let, ask, get or want. A name in passing asks nothing: "fix senior-dev's typo in
+this file" or "fix the line senior-dev changed in this file" stays here.
+
+**A commit, an undo or a revert stays here, whatever it names.** "revert senior-dev's
+commit", "commit senior-dev's changes" or "revert this commit with senior-dev" is done in
+the conversation, and a proposal for it is refused: a program works on a branch of its
+own and never moves yours, so it could not do it. `/senior-dev ` still starts it.
+
+**A correction does not undo the name.** Every message you type into one turn is read for
+the program's name, not only the newest. Name senior-dev, then type "the failing test is
+TestRetryUnderLoad" while it reads the code, and the name still holds for the rest of that
+turn and for a turn a finished task or job wakes to answer it: a proposal without the
+program is turned back as above, and "fix this file only" typed after the name still goes
+to senior-dev. A correction that does not name the program earns no second turn-back. Your
+next message that starts a turn of its own is read on its own.
+
+## Which folder a program works in — a repository I have not cloned, it edited files outside its folder, a folder with no git
+
+A program that edits code works in one folder itself, never a copy: the one the task
+names as its `ground`, or this conversation's own folder when it names none (a typed
+`/` names none). Nothing else moves it — not `where`, not a path in the brief, not
+where the conversation has been working — and the task's receipt names the folder.
+Inside a git repository it is the repository's root. A `ground` that is not there yet is
+made, empty, when the run starts, as long as the folder it would be made in is there.
+Only what it changes there is part of the task. While it runs the folder is the
+program's: codeaf's own file tools and tasks keep out of it (senior-dev's page says how).
+
+**It is never handed your home folder**, or a folder above it: that is not a project. A
+conversation opened in your home folder names the project's folder (making one first when
+the work is new), and a hand-off that names none is refused with ` works in one
+project's folder, and is your home folder; say which folder the work is in, as
+ground`. `/` typed there is refused the same way, and says to open codeaf in the
+project's folder or to ask in the chat and say which folder.
+
+So when the work belongs in a repository that is not on this machine (a benchmark task
+that names a repository and a commit), the model clones it first, into a new folder, at
+the commit the work names, and hands the program that folder, never a brief that sends
+it to work in another folder.
+
+A folder with no git history (a plain folder, a repository with no commit yet, or a
+folder in a repository whose root is your home folder) is worked in as it is, and codeaf
+tells the program so on the line it starts it with (senior-dev is given `--in-place`),
+from the chat and at a shell. Nothing is committed: its changes are already in the
+folder when it ends. senior-dev also reads this itself: it uses git only if git is
+there, so it never ends for want of a repository.
+
+At a shell, clone the repository yourself, then run `codeaf ` inside it, or name the
+folder with `--dir`.
+
+## What it cannot do — why it did not ask me, no questions, no step cap, no review round
+
+**It cannot ask you anything.** Nobody is at its keyboard. Write the brief so that
+everything it would stop and ask is already settled. The model is told the same thing when
+it proposes one.
+
+**It has no step cap.** It is held to this conversation's dollar and time limits. It is
+given them when it starts, and codeaf enforces them from outside as well: once the run's
+spend has reached the dollar ceiling, every further model call is refused before it is
+made, and the task then says ` reached the run's dollar ceiling of $…`. The call
+that crossed the ceiling was already paid for, so a run can end a little over it. On a
+service that reports no prices (a local proxy, a vendor's own API, a plan you signed in
+to) no call has a price to add up, so the dollar ceiling cannot hold: a time limit
+(`--max-hours`) is the bound there.
+
+**It has no review round.** codeaf's checker does not read its work afterwards. What the
+program itself checked is reported in its result, kept apart from what its model claimed.
+
+**It ends with the engine holding the conversation.** Leaving a hosted conversation's
+window only detaches it. If that engine stops or crashes, the conversation is closed, or a
+`--no-host` codeaf quits, the run ends with `codeaf closed while was running` where
+it was last seen working, or ` had ended; codeaf closed before it could say where its
+work is` at the program's exit; the next codeaf to find the run says where its work is,
+as it was left, and commits nothing. Nothing carries it on; the next hand-off starts a
+run of its own.
+
+## Why was the delegate refused — uncommitted changes, the folder is busy, it runs alone, no such program
+
+**It needs a clean checkout.** In a repository with changes that are not committed
+(modified, staged or untracked files), or a merge, rebase or cherry-pick half done, it is
+refused before anything starts, and nothing is switched or spent: ` has changes
+that are not committed (); commit or stash them, then ask again`, or ` is
+in the middle of a merge; finish it or abort it, then ask again`. The model reads this
+before you are shown a card.
+
+**One folder takes one program run at a time**, from any conversation, any window or a
+shell: ` is busy: , task 4 (…), is working in it, and one folder takes one
+program run at a time; ask again when that run has ended`. So do the folders inside it
+and around it: `… is working in , which holds it, …` (or `which is inside
+it`).
+
+**It runs alone.** While one is running, no other task can join it, and it cannot be
+started under another run of this conversation: `work is already underway in ;
+ runs alone, so propose it again when that work has ended` (`in a copy of
+` when the work underway is a task of codeaf's own). codeaf's own tasks run as the
+conversation's run too, so a `/task` typed in a conversation while senior-dev is working
+there is refused the same way, as a task that did not start; another conversation can
+run one, on a different folder.
+
+A name your build does not carry is refused with the ones it does:
+`this codeaf carries no program called ; it carries …`.
+
+## Where a delegate's work goes — its own branch, checked out in my folder, not merged into mine, not squashed, the wip commits, what it costs
+
+In a git repository codeaf cuts the program a branch of its own (`task/-`) in
+your folder and checks it out, and the program works there; its own commits (senior-dev's
+`wip(edit): …`) stay on that branch, and nothing squashes them. When it ends, codeaf commits what
+it left uncommitted onto that branch — the task's title, with the program's own account
+of the ending as the body — and **leaves the branch checked out**, so the work is in your
+folder. **Your own branch never moves**, and nothing is merged into it; if anything else
+moved it during the run, the page says so instead of `as it was`. The task's page
+and the conversation say ``its work is on the branch in , N files, and
+that branch is checked out there; your branch is as it was: `git -C ''
+switch ` goes back to it, and `git -C '' merge ` from there brings
+the work in``. Ask the chat to merge it, or run that yourself, when you are ready.
+
+A run you stop keeps its work the same way. A run that changed nothing leaves nothing:
+your branch is checked out again and the empty branch is deleted (`it changed nothing, so
+ is back on your branch and its branch was deleted`). If the
+program's own shell left the folder on another branch, codeaf commits and switches
+nothing and says where it was left. Its own notes (senior-dev's `.senior-dev/`) are moved
+out of the folder into the task's record folder, in any kind of folder.
+
+A program that only answers works in your folder in place and changes nothing. Its answer
+arrives in the conversation the way a task's landing does.
+
+What it spent is in the conversation's total, in `/cost` and on the status line. Every
+model call it made went through codeaf and is priced like one of codeaf's own. A run
+stopped in the middle of a call is not over until that call's price has come in, for at
+most 70 seconds, so the call it was cut in is in those figures too.
+
+## Why is there no command for it — missing, not in this build, Windows, a hosted conversation
+
+A program's command exists only in a build that carries it. On Windows codeaf carries
+none: their engines need a Unix shell, process groups and file locks, so the commands are
+absent there rather than failing every time.
+
+Over `--host`, the programs are the far machine's build's. The rows come from that build,
+and a run you start happens there, in that machine's folder, on a branch of its own.
diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md
index ceab09d4b6..4c07ea7b5e 100644
--- a/internal/manual/chat/home.md
+++ b/internal/manual/chat/home.md
@@ -2255,14 +2255,17 @@ to the cent.
zero or unknown files, spend and tokens are omitted, and a line with no true fact at all is
not drawn. The resting panels do not carry it — `spend` is the whole machine's day.
-**It counts the talking and the work the talking started, in one figure.** The turns —
-your messages, the answers, and the small calls beside them — are added up by the session
-itself and written to the session folder at the end of every turn, so home can read them
-without opening the transcript. Every task and every unattended run this conversation
-commissioned is added from the project's task index. `spent $1.25` is those two halves
-together.
-
-`tokens` is input plus output as one sum, over the same two halves. `touched 12 files` is how
+**It counts the talking and the work the talking started, in one figure, and each dollar
+once.** The session keeps one set of books: your messages, the answers, the small calls
+beside them, and every task and senior-dev run this conversation started, folded in as each
+one finishes. It writes that total to the session folder at the end of every turn and again
+when a task or run finishes, so home can read it without opening the transcript. The
+project's task index also carries each task's and run's own bill. `spent $1.25` is the larger
+of those two figures, never their sum: the books already hold every finished task and run, so
+adding the index on top would count them twice. While work is still running the index can be
+ahead, and then its figure is the one shown.
+
+`tokens` is input plus output as one sum, read the same way. `touched 12 files` is how
many files this conversation's work wrote, summed over its tasks.
`/cost` and `/status` inside the conversation still answer for the live session. A whole
@@ -2409,9 +2412,13 @@ keeps its status bullet, with the most recently active running conversation anim
A task awaiting your decision has its own question indicator; its parent conversation
does not repeat that indicator unless it has a separate question.
-## Does a run create another conversation in Sessions or the chats menu
+## Does a run create another conversation in Sessions or the chats menu — a run's tab, senior-dev's task has no tab
-A run’s tab is a view inside its parent conversation. Home’s Sessions list and the chats menu keep one row for that conversation, using its conversation title. The run’s own tab remains available beside it.
+No. Home’s Sessions list and the chats menu keep one row for the conversation, using its conversation title.
+
+A run the task-belt switch (`CODEAF_TASK_BELT=bash`) drives also has a tab of its own on the strip beside its conversation’s, named after the task the run is working on, for as long as the run works. It is a view inside that conversation: while it is open it is the one tab drawn selected, and a press on the conversation’s tab, a press on the `home` tab, or `esc` leaves it.
+
+A task handed to a program such as senior-dev has no tab. It opens inside the conversation’s own tab, as any task does, and `esc`, the conversation’s tab and the `home` tab leave it.
## Why does a closed conversation say another window
diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md
index 66eeeb9083..21104f6006 100644
--- a/internal/manual/chat/how-tasks-run.md
+++ b/internal/manual/chat/how-tasks-run.md
@@ -388,7 +388,8 @@ clear by hand.
**Two tasks cannot both run in place in one directory.** Whichever started first has it;
the second is refused its writes and told which task to wait for. When the first lands, the
-second gets the directory.
+second gets the directory. A program's run (senior-dev) holds its folder the same way, and
+a task is refused that folder before it starts: senior-dev's page has the words.
## A task that has written a file holds that file — I cannot edit a file while a task runs, chat edit blocked, single writer
@@ -456,7 +457,7 @@ line of its report — `files: site/index.html, site/app.css` — and only names
exist in its checkout are believed. A task that says nothing about them has left them
behind, and that is the difference between a deliverable and a dropping.
-## Why my task's branch was kept — I committed, amended, rebased or reset my branch while it ran, it did not merge, my checkout is on main or dev, tasks do not merge into a protected branch automatically, how do I take the work, why did the work not land in my checkout, why didn't my task merge, which branches does codeaf refuse to write
+## Why my task's branch was kept — I committed, amended, rebased or reset my branch while it ran, it did not merge, has codeaf committed to dev or main, my checkout is on main or dev, tasks do not merge into a protected branch automatically, how do I take the work, why did the work not land in my checkout, why didn't my task merge, which branches does codeaf refuse to write
A tag with the same name as a branch does not change which branch is protected
or which commit the landing compares. Git signature-display settings also do
@@ -3163,6 +3164,9 @@ and counted as the section above on work that is your call says. Ids can only po
and only ids `propose_task` itself returned count: a job or adaptive-run number is a
different kind of work, and naming one — or a task that already failed — refuses the
proposal on the spot instead of queueing work that could never start.
+A task handed to a program such as senior-dev cannot wait at all, because it starts the
+moment it is approved: its `depends_on` may name only work that has already landed. A
+program's run that ended done may be named by any task; one still going may not.
**`model`** — which model this task runs on. Set only when you asked for a particular model
or class of model for this work. Left out, the task runs on `task.model` if set, otherwise
diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md
index 3061164f4f..f71a43f9a0 100644
--- a/internal/manual/chat/keys.md
+++ b/internal/manual/chat/keys.md
@@ -2503,6 +2503,12 @@ and scroll the page one row only when there is no history to walk. `left` is
deliberately **not** taken here — it falls through to the message box's
back-navigation.
+**Inside a program's room** — a task handed to senior-dev — `ctrl+y` turns the page
+between the actions it took and its raw calls to its model, `ctrl+o` folds its brief, and
+the box sends nothing. While it works the keys row under the box reads
+`/stop · x with empty input · esc main · ctrl+y calls`, ending `ctrl+y actions` while the
+calls are showing; once it has ended the row is the `ctrl+y` clause alone.
+
**`up` and `down` in a room mean what they mean in the message box**, in the same order:
inside a multi-line message they move the caret; on the first line — or over an empty box
— they walk your own history, newest first; and only with nothing to walk do they scroll
@@ -2568,6 +2574,18 @@ card, and the card is answered below.
The tasks pages describe what rooms and the roster are for.
+## See a program's raw calls — ctrl+y on senior-dev's page, the model calls behind its actions
+
+A program's task page — senior-dev's — opens on the actions it took, each under the step
+of its process. **`ctrl+y` turns it to the raw calls** it made to its model: what it sent,
+what the model answered, which model it was, and the call in flight. `ctrl+y` again turns
+it back. It works in the program's room, whichever door opened it — its row, its card, or
+the tasks place — and the key row names it: `ctrl+y calls` over the actions, `ctrl+y actions`
+over the calls. Every page opens on the actions.
+
+It is a chord, so it never costs a character: the room's box keeps what you typed. It is
+not bound on any other task's page.
+
## Stopping work with `x` — the confirmation card, why the stop card needs enter as well as the number
`x` raises one card above the message box:
@@ -3137,7 +3155,8 @@ answer:
| `ctrl+r` | Bound. In the message box it is **spell it out** — see "Make my prompt better" above — in the `/files` list it opens the folder a file is in, and in the `/model` picker it fetches the newest model list. Nowhere else |
| `alt+e` | **Bound**, on three surfaces: it moves how hard the thing you are standing on thinks — this conversation from the message box, a task, or a standing item on home. The machine's own default is the `thinking` row of `/settings` and is not on this chord. See "The thinking chip above the message box" and "alt+e — how hard the thing you are looking at thinks". Anywhere else it does nothing. On macOS it is shown as `opt+e`; the terminal must send Option as Alt/Meta, as for the other Option shortcuts |
| `ctrl+x` | Bound in three places: it drops a harness design from inside its room; on home it stops a standing item for good; and on a `tasks` row of home that this window holds it asks to stop that task (`ctrl+x stop it` on the `alt+.` map; the foot under a field row is the resting sentence and does not name it). Not bound anywhere else |
-| `ctrl+y`, `ctrl+z` | Not bound |
+| `ctrl+y` | **Bound in two places**: on a program's task page — senior-dev's room, however it was opened — it turns the page between the actions the program took and its raw calls to its model; in the `/files` list it copies the file under the cursor. Nowhere else |
+| `ctrl+z` | **Bound**: undo in every box, with `ctrl+shift+z` to redo — see "Undo what I typed" |
| `ctrl+` | **Bound as a second spelling of the place keys, on the terminals that report they can send it.** `ctrl` and a digit has no encoding in the scheme most terminals speak, which is why `alt+1` … `alt+8` (`opt+1` … `opt+8` on a Mac) are the first spelling and always will be, but a terminal running the kitty keyboard protocol sends it and says so, and where that report arrives `ctrl+1` … `ctrl+8` reach the same seven places. The map's line says `alt+1…8 or ctrl+1…8 go to a place` exactly when the alias is live. Where the terminal has said nothing, the chord does nothing and is never drawn |
| `ctrl+.` | Two meanings, on two screens that cannot both be up. In a conversation it is every task this project has run (`/history`); while a place is standing it draws the key map, on the terminals that can send `ctrl+` |
| `alt+` | Bound **only where a place says so, and only on that place**. `alt+s` changes the shelf on the memory place; `alt+b` and `alt+f` are the word jumps inside every box and are never taken by a place. Every other `alt+` does nothing |
diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md
index a98f535129..15b1b656cd 100644
--- a/internal/manual/chat/models-and-cost.md
+++ b/internal/manual/chat/models-and-cost.md
@@ -1497,8 +1497,13 @@ one late line to the machine's usage ledger. That line is marked `reconciled`, m
figures came from the receipt rather than the cut stream. A losing rescue arm is recorded as
hedged waste from its own receipt too; it is real provider money, but it is not added twice.
+codeaf asks for the receipt at once, then again about 1, 5, 20 and 40 seconds after the call
+ended. The receipt for a call cut in the middle usually takes the router about twenty seconds
+to price. A call senior-dev made that arrived whole but with no usage block is asked about
+the same way.
+
When no generation id arrived, the base has no receipt route, or the receipt still cannot be
-had after the short retry schedule, codeaf writes an `unbilled` marker with no invented
+had after that schedule, codeaf writes an `unbilled` marker with no invented
price or token count. The marker survives a restart. `/cost` counts missing prices for this
conversation and its tasks; `/spend` counts the markers in its selected time window. Both
say, for example, `2 calls the provider charged for and could not be priced`. At zero they
@@ -2099,7 +2104,7 @@ which is the whole machine's ledger rather than this conversation's — it was a
|---|---|
| `spend` | the money, printed only when it is above zero — this conversation **and every task it started** |
| `conversation` | what the conversation's own calls cost |
-| `tasks` | what the work it started has cost, running or finished — tasks and the nodes of an adaptive run |
+| `tasks` | what the work it started has cost, running or finished — tasks, the nodes of an adaptive run, and a task handed to a program such as senior-dev |
| `tokens` | `48.1k in · 3.2k out`, or one half alone, or the combined figure |
| `cache` | `31.2k read · saved $0.0180` — the money half only when a price pair was published |
| `model calls` | **requests to the provider**, deliberately not "turns" |
@@ -2131,6 +2136,12 @@ is the same figure `/cost` leads with.
started: its money is on the row while it is still working, under `tasks` when you ask
`/cost` for the halves.
+**So is a program's run.** Every model call senior-dev (or another program codeaf carries)
+makes for a task this conversation handed it names this conversation and that task on the
+ledger, and it is on the row as it is spent, under `tasks` in `/cost`, and under the task
+on the spend place. Its tokens and its calls reach this conversation's `tokens` and
+`model calls` lines as well.
+
It used to be the conversation's own half alone. A task's money only reaches the
conversation's books when the task **closes**, so a family working for two hours left the
row saying `$2.53` while $51.05 was being spent under it, and the true figure could only be
@@ -2178,8 +2189,9 @@ than the number of times you have spoken.
It counts every request that is written down, not only the ones in your turns: naming the
session, a judge deciding where something should be routed, looking at a picture, every
-request a task's own agent made on its own lane, and every request a harness run made
-while it walked its program. That is deliberate, because the `spend` line above it is the
+request a task's own agent made on its own lane, every request a harness run made
+while it walked its program, and every request a program such as senior-dev made for a
+task this conversation handed it. That is deliberate, because the `spend` line above it is the
sum over exactly those requests — a smaller count beside it would be a bill divided by the
wrong number.
diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md
index 1dba209b64..a7e3f034a8 100644
--- a/internal/manual/chat/places.md
+++ b/internal/manual/chat/places.md
@@ -258,7 +258,7 @@ the one you opened it for. With nothing pinned the rung and the gate are what a
would run at: the `thinking` and `ask before running` rows in `/settings`, or `--yolo` if
this process was started with it.
-**The `here ~/codeaf` chip is gone**, and so are the rules that the other places used to
+**The `here ~/codeaf` chip that sat next to the box is gone**, and so are the rules that the other places used to
draw over their boxes. The arrow and `new conversation in` lead are gone from home too;
the model starts the seam, and the project sits at the far right of the keys row under the box. A place with something to say about its page — `nothing matches`
on tasks when a filter emptied it, a receipt on memory, the "this session is on another
diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md
index 47ed4f7dc9..974f4bcb01 100644
--- a/internal/manual/chat/reading-a-task-page.md
+++ b/internal/manual/chat/reading-a-task-page.md
@@ -41,6 +41,15 @@ Nothing here is thrown away — what is folded is one keypress from open.
`started 14:02` only when the record carries the instant the work began. Reopening the
conversation does not replace that instant with the time you sat down.
+**How long it ran is the record's too.** The settled page's header and the completion card
+show how long the work took, rounded to the second — one figure, spelled `29m 8s` in the
+header and `29m08s` on the card. It is the time the work itself took, as the record gives
+it: the time you took to answer a task that landed needing your look is not added, so a task
+that worked five minutes and that you accepted an hour later still reads `5m`. A record
+that gives no such figure but carries both instants shows the landing less the start. While
+the work runs, the side list counts from the record's start, even in a window opened after
+it began.
+
An older record may carry a duration but no start or landing instant. When that duration is
at least one second, the settled task page shows it in the header — for example `12m` —
while the completion card omits the entire `started 14:02` segment. A shorter or absent
diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md
new file mode 100644
index 0000000000..9f8eba106d
--- /dev/null
+++ b/internal/manual/chat/senior-dev.md
@@ -0,0 +1,676 @@
+# senior-dev
+
+## What /senior-dev does — hand one large change to senior-dev, an autonomous coding agent
+
+`/senior-dev ` hands the whole brief to **senior-dev**, an autonomous coding agent
+codeaf carries. At a shell the same program is `codeaf senior-dev `. It is built
+into codeaf and runs only through it: there is nothing to install and no senior-dev of
+its own to start.
+
+It works alone in your folder itself, on a branch of its own when the folder is a git
+repository, and your own branch never moves. It writes your brief down word for word, reads
+the repository, keeps a checklist of what the brief asks for, pins a command that shows
+the work passes, and edits until it believes the change is done. Then it **submits**:
+the tree is frozen at that moment, so nothing it does afterwards can change what it hands
+back. It then runs the project's own build and tests on the frozen tree, and if anything
+moved after it submitted, the tree is put back to what it submitted.
+
+It is for complex, multi-part coding work: fixing an issue in a mature codebase whose
+cause spans files, a feature with its tests, a rewrite across a package, a migration.
+codeaf hands work like that to it by itself, and uses it whenever you name it (the next
+section). Its brief has to settle everything, because nobody will be asked anything.
+
+## Will codeaf use senior-dev by itself — when does codeaf hand work to senior-dev, how do I make codeaf use senior-dev, stop it using senior-dev
+
+**Yes, for the work it is for.** The chat's model is told to hand complex, multi-part
+coding work to senior-dev, whole, rather than doing it in the conversation or giving it
+to codeaf's own worker: fixing an issue in a mature codebase whose cause spans files, a
+feature with its tests, a rewrite across a package, a migration. It proposes the task
+with `via` naming senior-dev, and the card goes up like any proposal's, with its
+countdown. A change you would make in a few steps it still makes itself.
+
+**Naming it is enough.** Say senior-dev in your message, in any spelling: "fix issue 412
+with senior-dev", "/senior-dev should take this", "senior dev". The model is told to use
+it, and if it proposes the work without senior-dev anyway, codeaf turns that proposal
+back once, with any others in the same reply, and tells it you named senior-dev. That
+holds even for a one-file fix you ask senior-dev for, which otherwise stays in the
+conversation, and through a correction you type while it works that does not name
+senior-dev again. A commit, an undo or a revert stays in the conversation even when it
+names senior-dev ("revert senior-dev's commit"): senior-dev works on a branch of its own
+and never moves yours.
+
+**Saying not to is kept too.** "don't use senior-dev for this" names it, so the first
+proposal is turned back the same way; the model reads that and proposes it again as it
+was, and that proposal passes.
+
+**Typing `/senior-dev `** starts it at once, with your brief word for word and no
+card. Work codeaf moves to a task on its own, because a reply ran long or looked like
+work, goes to codeaf's own worker, never to senior-dev.
+
+## Watching senior-dev work — open its task, what it is doing step by step, how long it has run, stop it
+
+A senior-dev run is a task of the conversation that started it. Its row is on the side
+list wearing `[senior-dev]` after its title, with the step it is in and what it has spent
+so far under it, and a card in the conversation lands when it ends. Click the row or the
+card, or follow a task link to it, and its task opens **inside the conversation's own
+tab**: the tab strip stays on top, with the conversation's tab selected and the `home` tab beside
+it. senior-dev gets no tab of its own.
+
+The task shows **what senior-dev is doing**, action by action, each under the step of its
+process it served — the workspace it set up, what it read and ran and changed, its
+hand-in, the build and tests it ran itself, and how it finished — with the call to its
+model in flight as the last line, `◐ thinking` and its seconds. The next section says what
+each step means. **Click an action to see the whole step** — the command or file it was
+called with and what came back — and click it again to fold it. **Every change to your
+files wears git's `+N,-M`** at the right of its line — the lines it added in green and the
+lines it removed in red — so you can see how much each step moved the work; senior-dev's
+own spec, pinned check and checklist wear none.
+
+The one line over it is the task's title with its `[senior-dev]` badge, a `▸ brief`
+dropdown, and the step, the spend of the run's ceiling, the number of model calls and how
+long the run has been going — the same time the side list and the landed card show,
+counted from the moment codeaf handed the work over. **The brief is behind the dropdown**:
+click `▸ brief`, or press `ctrl+o`, and the whole brief senior-dev was handed is drawn in
+grey under the title; the same again hides it. While the
+task is open, its row on the side list leaves its clock out rather than show a time that
+stopped when you clicked; the true time is back on the row the moment you leave.
+
+**The raw calls are one key away.** `ctrl+y` turns the page to senior-dev's calls to its
+model — what it sent, what the model answered, and which model it was — and `ctrl+y`
+turns it back; the key row says `ctrl+y calls` or `ctrl+y actions`.
+
+`esc`, a press on the conversation's tab, or a press on the `home` tab leaves it, and the run goes
+on. `x` over an empty box, `/stop`, or `Stop` on that line asks `Stop this task?` first.
+Nothing typed there reaches senior-dev: the box says `senior-dev reads no messages — say
+it to main`, and `enter` says the same line and keeps your words in the box.
+
+## What is senior-dev doing — the steps on senior-dev's page, what spec, explore, pin, checklist, implement, submit, verify mean
+
+The word down the left of senior-dev's page, and on its row while it runs, is the step of
+its own process an action served. senior-dev has no planner, reviewer or helper agent:
+one model works through the middle steps in the order it chooses, so a step's word comes
+back whenever it returns to that step.
+
+- `setup` — it set up the folder it works in: `git`, or `no git history` for a plain
+ folder, whose checkpoints it keeps outside it.
+- `spec` — it wrote your brief down word for word as its spec, and read it back.
+- `explore` — it read, searched and ran commands before changing any file.
+- `pin` — it wrote down the one command that shows the work passes.
+- `checklist` — it listed what the brief asks for, and ticked it off.
+- `implement` — it changed files, and everything it read or ran after its first change.
+- `submit` — it handed in its work: `handed in its work · 4 files · 5 of 5 ticked`, or
+ `its hand-in was refused` and why. The work is frozen at that moment.
+- `verify` — with no model, it ran the project's own build and tests itself, one line per
+ command with `passes` or `fails · exit N`, then what they came to. It also checks the
+ tree this way when its model stops without handing in.
+- `finish` — what it did to the tree it leaves, the size of its change, and its ending.
+
+Lines with no word of their own are senior-dev steering its model in the step already
+under way, drawn quieter: `told its model what it found, and to finish and hand in (nudge
+1)`, `time is short: gave its model one last turn to finish`, a dropped call retried, a
+tool call written as text corrected — and `compacted its memory` and `switched to `
+with its reason.
+
+## How do I tell a senior-dev task from a normal task — the [senior-dev] badge, [sd], what the brackets on a task mean
+
+A task handed to senior-dev wears its name as a badge wherever a task is named:
+`[senior-dev]`, bold in the accent colour, after the task's title. A normal task — one
+`/task` starts, or one the chat hands to codeaf's own worker — wears no badge.
+
+- **The side list** wears it after the title. When the list is too narrow for
+ everything, the task's number (`#7`) goes first; then the badge shortens to its
+ initials, `[sd]` — the narrower list a frame under 120 columns draws reads
+ `⠋ rewrite the… [sd] #7` — and the title is cut last. Widened with `w`, the list has
+ room for the whole badge and the number.
+- **The card you answer** asks `wants to start a [senior-dev] task: `, and the
+ card's top line wears the badge beside the task's name.
+- **The task's own page** wears it beside the title — a page onto another
+ conversation's task too, with that task's own badge and never the one a task of the
+ same number in this conversation wears.
+- **The task strip**, the row of chips that stands in for the side list under 100
+ columns, wears `[sd]`.
+- **The `@` list, the tasks place and home** — its list of work, a landing under
+ `needs you` and a line under `since you left` — wear `[senior-dev]`, or `[sd]` where
+ the row is short of room. The title is cut before the badge, and a `since you left`
+ line cuts what the work came to first: `rewrite the auth middleware [senior-dev] · it…`.
+- **The chat's `tasks` tool** says `via senior-dev` on the row, so the chat can tell too.
+
+The brackets are always drawn, so a terminal with no colour, the row of the task you
+have open, and a screen reader all still show the badge. It is not a button: a press
+anywhere on the row opens the task.
+
+## How do I ask senior-dev for a change — writing the brief, what to put in it
+
+The brief is everything senior-dev knows about what you want. It is saved as
+`.senior-dev/spec.md` in the folder it works in exactly as you wrote it, and it is read
+back from there whenever senior-dev summarises its own history, so the words you chose
+are never paraphrased away.
+
+Write it the way you would hand work to someone who cannot reach you:
+
+- the files, packages or commands involved, by name;
+- what done means, and how to check it (the test to run, the output to see);
+- the constraints: what must not change, and the wrong answer to avoid.
+
+In the chat, `/senior-dev` followed by the brief starts it as a task. At a shell, flags go
+before the brief, and `--` ends them: `codeaf senior-dev run --variant high -- rename the
+config loader`. Everything from the first word that is not a flag onwards is the brief,
+so a flag written after the brief becomes part of it.
+
+## Running senior-dev on a repository you have not cloned — a benchmark task, another project
+
+senior-dev works in the folder it is handed and nowhere else, so it has to be handed the
+repository the work belongs in.
+
+In the chat, ask for the work and name the repository, and the commit if the work names
+one. The model clones it first, into a new folder, at that commit, and hands senior-dev
+that folder. A benchmark task works this way: senior-dev works in the project's own
+repository and not in the benchmark's, so the benchmark's files, its reference solution
+among them, are not in its folder.
+
+At a shell, clone the repository yourself, then run `codeaf senior-dev` inside it or pass
+the folder with `--dir`.
+
+A brief that names the folder it works in is fine: senior-dev reads it as written. A
+brief that tells senior-dev to make a checkout of its own somewhere else does not work:
+its file tools refuse to write outside its folder, and what a shell command changes out
+there is not part of the task.
+
+## What senior-dev cannot do — it cannot ask you anything, wait on another task, be retried or carried on, no step cap, no Windows
+
+**It cannot ask you anything.** Nobody is at its keyboard: a question its model tries to
+ask is turned down inside the program, and after three it is told questions are not
+available. Put everything it would stop and ask into the brief.
+
+**It cannot wait on another task.** A task handed to senior-dev starts the moment it is
+approved, so a proposal whose `depends_on` names work that has not finished is refused
+before its card: `depends_on names task 3, which has not finished, and senior-dev starts
+the moment it is approved — it cannot wait. Propose it again once task 3 has landed, or
+with depends_on left out if nothing must finish first.` The chat is told when that task
+lands and can propose it again then. The other way round, a task may name a senior-dev
+run that ended done in its `depends_on` (the run's work is on its branch, in its folder),
+but not one still going: `depends_on names task 5, a program's run that has not ended,
+and a task cannot wait on one.`
+
+**A run is never resumed, but codeaf may send the work back.** A run that ended is not
+started again: `senior-dev's run is never carried on: its work is left where it ended, and
+a new hand-off starts a new run`. Its card offers no retry, and the `@` list offers no
+steer on a running one, because it reads no messages. What codeaf does instead is the next
+section.
+
+## What codeaf does when senior-dev ends — its ending, checked, sent back, retry, at most twice, ask before spending more
+
+**senior-dev's ending goes to the chat, not to you.** The moment a run ends, the
+conversation wakes on its own with how it came out — passed its own check of the project,
+nothing finished checking it, handed in work that does not pass, stopped on a limit, or
+broke — and acts on it:
+
+- **passed**: the chat looks at what changed against what was asked, then tells you where
+ the work is and offers to merge its branch;
+- **nothing checked it**: the chat runs the project's checks on its branch itself, then
+ acts on what they show;
+- **does not pass, or did not finish**: the chat fixes a small gap on its branch itself, or
+ hands the work back to senior-dev with a brief sharpened by what failed;
+- **stopped on a dollar or time limit**: the chat never sends it back on its own, because
+ another run spends more of your money: it says what is done and what is left, and asks;
+- **broke**: the chat hands it back once if the cause looks passing (a network or model
+ service failure), and otherwise tells you what broke.
+
+**codeaf sends senior-dev back at most twice on its own** for one piece of work. A third
+hand-off it tries, or one after a limit, is refused
+(`senior-dev has been sent back to this work 2 times already, the most codeaf does on its
+own: tell the person where the work stands and let them decide`), and you decide. A
+hand-off you ask for yourself is yours, and starts the count again. Each hand-off still
+shows its card, with the same countdown as any other, so you can stop one.
+
+**Every run on the same work stays on one branch.** A run handed a folder that the last
+senior-dev run left on its branch carries on on that branch rather than cutting another:
+`it works alone in itself, carrying on on its branch , where the last run
+left it; your branch main does not move`. Its ending names your own branch, and a run that
+adds nothing never deletes what an earlier one committed. Switch the folder to another
+branch first and the next run cuts its own.
+
+**The card stays quiet.** senior-dev's landed card says the run `ended` (never a red
+cross) and `senior-dev's ending went to the chat`; the chat's own reply is where you read
+what came of the work. `ctrl+o` on the card still shows senior-dev's own words.
+
+**It has no step cap.** It is held to the conversation's dollar and time ceilings instead,
+and codeaf enforces both from outside whatever it does. On a service that reports no
+prices the dollar ceiling cannot hold, and a time limit is the only bound (see the section
+on services that report no prices).
+
+**It reaches a model only through codeaf.** It holds no key and reads none; a
+`senior-dev.json` in your folder that sets `apiKey`, `baseURL` or `providerRouting` is
+refused by name, because codeaf decides which model service serves each call.
+
+**It writes only inside its folder.** Its file tools (`write`, `edit`, `apply_patch`)
+refuse any path outside the folder it was handed, including one reached through a link,
+and say so to its model; it can still read files elsewhere. Its shell is not fenced the
+same way, and nothing a shell command changes outside the folder is part of the task.
+
+**It keeps its record in git if git is there**, on its own branch. Where there is no git
+history it keeps its checkpoints outside the folder instead and commits nothing — it
+reads that itself, and never ends for want of git (see the section on folders that are
+not a git repository).
+
+**On Windows it is absent**: there is no `/senior-dev` and no `codeaf senior-dev`. Its
+engine needs a Unix shell, process groups and file locks, so Windows builds leave it out
+rather than carry something that fails every time.
+
+## Can I run senior-dev in a folder that is not a git repo — a plain folder, no git, --in-place, operation not permitted, .Trash
+
+Yes. **senior-dev uses git only if it is there.** A folder with no git history — a plain
+folder, a repository with no first commit yet, a broken `.git`, a machine with no git — is
+worked in as it is: senior-dev reads that itself when it starts, keeps its checkpoints
+outside the folder and makes no commits. A folder inside a git repository whose root is
+your home folder (a dotfiles repository) is worked in the same way, because codeaf starts
+senior-dev with `--in-place` there: no branch is ever cut in your dotfiles.
+
+When it ends its changes are already in the folder. The task's page says `its work is in
+, which has no git history, so nothing was committed` (or, under a repository at
+your home folder, `its work is in ; the git repository around it is at ,
+which holds your home folder, so codeaf cut no branch there and committed nothing`).
+
+It works in your folder itself, so leave that folder alone while it runs: once it has
+submitted, anything changed there is put back to what it submitted, and a file added
+there is removed. codeaf's own file tools and tasks keep out of it until then.
+
+**A folder or file in it that senior-dev may not read is skipped**, not a reason to stop:
+it is in none of its checkpoints, and nothing of it is changed or removed. senior-dev
+needs no Full Disk Access; a folder macOS keeps to itself (`operation not permitted`) is
+skipped like any other. It is never started on your home folder or a folder above it
+(see the programs page): to check what it changed, it reads every file in the folder,
+and your home folder is not one project.
+
+senior-dev used to stop at once there with `workspace is not a git repository:
+; run with --in-place to work in a plain folder`, which the chat could not act on.
+It no longer does, whatever flags it is started with.
+
+## Why can't codeaf edit files while senior-dev is working — the folder is senior-dev's while it runs, a write or a task refused, bash, your own editor
+
+senior-dev works in your folder itself, so **the folder is senior-dev's until the run
+ends**: once it has submitted, anything changed there is put back to what it submitted
+and a file added there is removed, and what is left over is committed as its work. So
+nothing else of codeaf's writes there meanwhile, from any conversation, window or shell:
+
+- the chat's `write` and `edit`, `edit_video`, and a picture, music, video or speech
+ saved at a path there are refused: ` is in , where senior-dev, task 4
+ (Fix the parser), is working, so nothing was written; wait for that run to end, or stop
+ it, then write there`. Reading stays open.
+- a task on that folder, inside it or around it — proposed, typed with `/task`, a quick
+ task, or one whose turn to start comes — is refused before it starts: ` is
+ busy: senior-dev, task 4 (Fix the parser), is working in it, and nothing else of
+ codeaf's works there until that run has ended; wait for it, or stop it, then ask again`.
+- a task already running when it started lands beside it: `its branch was kept:
+ senior-dev, task 4 (…), is working in it — bring it in when that run has ended`. A
+ `/land` of the chat's changes there is refused the same way, and waits.
+
+**`bash` is not fenced**: codeaf cannot know what a command writes. **Neither is your
+own editor**: what you save there while it runs joins its work, or is put back.
+
+## Its notes — .senior-dev, its checklist, its session database, moved out when it ends
+
+senior-dev keeps its own records in `.senior-dev/` in the folder it works in: the brief,
+its checklist, the command it pinned, its session database and its whole conversation
+with its model. **They are moved out of your folder when the run ends or you stop it**,
+into the task's record folder beside `delegate-conversation.jsonl` (a shell run's record
+folder at a shell), and the page adds `its notes (.senior-dev/) are kept in `. So
+they never end up on a branch, and the next run in that folder never reads the last
+one's checklist as its own. A `.senior-dev/` already in the folder when the run began is
+left where it is, and never ends up on a branch either.
+
+## Where does senior-dev put its work — its own branch, checked out in your folder, not merged, not squashed
+
+In a git repository, codeaf cuts a branch of its own for the run (`task/-`)
+in your folder and checks it out there, and senior-dev works on it. senior-dev commits
+every file it writes (`wip(write): `, `wip(edit): `) on that branch, which is
+how it keeps a record to restore from; they stay there, and nothing squashes them.
+
+When the run ends — finished or not, stopped, or crashed — codeaf commits whatever it
+left uncommitted onto that branch, in one commit whose subject is the task's title and
+whose body is senior-dev's own ending (unless codeaf itself closed first: then nothing
+is committed), and **leaves the branch checked out**, so the work is in your folder
+when you look. Nothing is merged into your own branch. The task's page
+and the conversation both say ``its work is on the branch in , N files,
+and that branch is checked out there; your branch is as it was: `git -C ''
+switch ` goes back to it, and `git -C '' merge ` from there brings
+the work in``. Merge it when you are ready, or ask the chat to.
+
+The ending keeps two witnesses apart: what senior-dev's model said it did
+(`senior-dev's model said: …`) and what senior-dev saw when it ran the project's build
+and tests (`senior-dev observed: …`). Read the second for "did it work".
+
+**A run you stop keeps its work the same way**: the stop says `its work so far stays on
+its branch , checked out in ` at once, and the page then says where it is
+in the words above. A merge senior-dev's shell left half done is never committed: the
+page says what it left `could not be committed ( is in the middle of a merge)`.
+
+**A run that changed nothing leaves nothing**: your own branch is checked out again, its
+empty branch is deleted, and the page says `it changed nothing, so is back on
+your branch and its branch was deleted`.
+
+## Does senior-dev change my branch — your branch never moves, going back, a HEAD it moved, my branch moved during the run
+
+No. Your branch (or, when your checkout was on no branch, the commit it was on) is
+written down before senior-dev starts, and codeaf never writes to it, resets it or
+merges into it. After the run your folder is on senior-dev's branch; `git -C ''
+switch ` goes back, and the page names the exact command. From no branch it
+names `git -C '' switch --detach `. codeaf's own switches run with your
+repository's hooks turned off: both go between two names for one commit, so a hook has
+nothing to do there.
+
+senior-dev's shell can still run `git checkout`, and a brief that says "work on a new
+branch" makes that likely. **So a brief need not ask for a branch: the work already has
+one.** If HEAD is not on its branch when the run ends, nothing is touched, and the page
+says where HEAD is: `senior-dev left on the branch instead of its own
+branch , so codeaf changed nothing there: nothing was committed and nothing was
+switched; holds N files` (or `on no branch, at `).
+
+**codeaf reads your branch again before it says it is as it was.** If something moved it
+during the run, the page says `your branch moved during the run, from
+to , and codeaf did not move it: look at it before you push or merge it`, and a
+run that changed nothing is not switched back onto it: `it changed nothing, but your
+branch moved during the run, from to , so codeaf did not switch
+back to it: its empty branch is still checked out in `. A branch deleted
+meanwhile reads `your branch is gone: it was at when the run began, and
+codeaf did not make it again`.
+
+## senior-dev refused: changes that are not committed — a dirty checkout, uncommitted changes, a merge in progress
+
+senior-dev works in your checkout itself, so it starts only on a clean one. **A repository
+with changes that are not committed — modified, staged or untracked files — is refused
+before anything starts**, nothing is switched and nothing is spent: ` has changes
+that are not committed (a.go, b.go, c.go and 2 more); commit or stash them, then ask
+again`. senior-dev's own `.senior-dev/` does not count. A checkout in the middle of a
+merge, a rebase, a cherry-pick or a revert is refused the same way: ` is in the
+middle of a merge; finish it or abort it, then ask again`.
+
+In the chat the model is told this before you are shown a card, and can commit or stash
+the changes itself if you ask it to; at a shell the run prints `error:` and the sentence,
+and leaves.
+
+## senior-dev refused: the folder is busy — one run per folder, a folder inside it, another window, a shell run
+
+One folder takes one senior-dev run at a time, from any conversation, any window or a
+shell. A second is refused, naming the one working there: ` is busy: senior-dev,
+task 4 (Fix the parser), is working in it, and one folder takes one program run at a
+time; ask again when that run has ended` (or `senior-dev, a run started at a shell`).
+
+**So are the folders inside it, and a folder around it.** A run on a folder of projects
+puts back whatever changed anywhere under it once it has submitted, so a run in one of
+those projects is refused too, naming the folder held: ` is busy: senior-dev,
+task 4 (…), is working in , which holds it, and one folder takes one program
+run at a time; ask again when that run has ended` (`which is inside it` the other way
+round). Two runs in two folders side by side both go.
+
+The hold goes with the codeaf holding it, however it ends, so a crash never leaves a
+folder refused.
+
+## What a senior-dev run costs — model calls, the dollar ceiling, which models
+
+Every model call senior-dev makes goes through codeaf, which serves each run its own
+model API. So every call is priced like one of codeaf's own, shows in the conversation's
+total, its tokens and its call count, under `tasks` in `/cost`, and under the task on the
+spend place. What the whole run came to is on its row, its landed card once opened
+and the chat's `tasks` tool (`#3 · … · done · ran 22m 51s · $2.30 · via senior-dev`).
+Every call is held to the run's dollar ceiling: **once the run's spend has
+reached it, codeaf refuses every further call** before it is made, with
+`the run's dollar ceiling of $5.00 is reached ($5.04 spent), so codeaf made no call`.
+The call that crossed the ceiling was already made and paid for, so a run can end a little
+over it. A refused call ends senior-dev's turn; it runs the project's build and tests on
+the tree it has, and ends there, and the task says
+`senior-dev reached the run's dollar ceiling of $5.00: …` with senior-dev's own words
+after it. A run handed off after the conversation's dollar limit is already spent starts
+nothing and makes no call: its row ends at once with `a dollar limit you set stopped it`.
+
+The time ceiling is kept by senior-dev as well as by codeaf. It holds back the last part
+of its time to land: two fifteenths of the run, at least 45 seconds, at most 12 minutes,
+and never more than a quarter of it. When that window opens it gets one last turn to
+submit.
+
+**When none of your model services can serve the model it asks for**, codeaf answers the
+call on the run's own work model — the one a task's own worker would use — and the raw
+calls on the task page (`ctrl+y`) name the model that answered. When nothing here can serve
+that model either, the conversation's own model may answer instead, and the page names
+whichever model did. A dated build or a variant of the model it asked for, such as
+`deepseek/deepseek-v4-pro-0731` or `qwen/qwen3.6-plus:free`, is that model and is not named
+again; a sibling such as `openai/gpt-5.5-mini` answering for `openai/gpt-5.5` is a different
+model and is named. Which models it asks for is the next section.
+
+## senior-dev on a service that reports no prices — a local proxy, a Codex sign-in, the dollar ceiling does not hold, set a time limit
+
+Some model services answer without saying what a call cost: most of the services you
+connect in `/connect` besides the default router, such as a local proxy or runner, a
+vendor's own API, or a plan you signed in to such as Codex. codeaf never guesses a price,
+so each call
+senior-dev makes through one is counted with its tokens and no dollars. The task page,
+the rail and the spend place show no money for those calls, never `$0.00`, and a missing
+price does not mean the service charged nothing.
+
+**So the dollar ceiling cannot hold there.** A run whose calls report no price never
+reaches its dollar ceiling, whatever it is set to, and senior-dev's own `--max-cost` adds
+up the same missing figures. codeaf does not refuse such a run or estimate its cost.
+
+**On such a service the bound that holds is a time limit.** Start codeaf with
+`--max-hours`, or give a shell run `--max-hours`, before you hand the work off. With no
+time limit, the run ends only when senior-dev finishes or you stop it.
+
+## Why a stopped senior-dev run takes a moment to end — the price of the call it was in the middle of
+
+When you stop a run, or codeaf ends it at its dollar ceiling, senior-dev is usually in
+the middle of a model call. That call is still paid for, and the router prices a call cut
+off like that by a receipt codeaf fetches afterwards, usually about twenty seconds later.
+**The run is not over until that receipt is in**, for at most 70 seconds, so the task's
+spend, the run's total and the conversation's `/cost` all include that call. A shell run
+waits the same way before it prints its last line.
+
+A receipt that never comes is kept as a call nobody could price, never as a free one
+(the section `Was I charged for a reply that got cut off` says where those are counted).
+A senior-dev call answered whole whose answer carried no usage block at all is asked
+about the same way, including one codeaf then set aside because it was not usable text:
+priced by its receipt, or kept as a call nobody could price. codeaf never guesses a figure
+for either.
+
+## Which models does senior-dev use — your crew, a model you ask for, its own list, --high
+
+**Ask for a model and it works with that one.** Say which in the chat — "use senior-dev
+with kimi-k2.6", or several: "with kimi-k2.6 and deepseek-v4-pro" — and senior-dev works
+with exactly those, routing among them call by call when there are several; the card and
+the task's first line name them. A name that fits more than one model is put to you to
+settle. A model none of your connected services can serve is refused before the card, by
+name, rather than swapped for another. A model senior-dev's model catalog does not know how to size cannot be used: the
+run ends before its first call with `senior-dev cannot work with : …`, and nothing
+is spent. The models are fixed when the run starts; changing the crew later does not move
+a run already working. `/senior-dev` typed with a brief uses your crew.
+
+**Otherwise, from the chat it uses your crew.** codeaf hands senior-dev two of the conversation's
+crew: the worker (hands) model is the one it works with, and the low model its history
+summaries. Change the crew and the next run follows. The mastermind (brain) model is not
+used: every call senior-dev makes is either its work or a history summary.
+A crew model senior-dev's model catalog cannot size is left out, and its log says so;
+if that leaves no working model, it uses its own list instead.
+
+**Its own list** is six open models it routes among call by call, avoiding one for a
+while after it fails: deepseek-v4-flash, deepseek-v4-pro, qwen3.6-plus, kimi-k2.6,
+glm-5.1 and minimax-m2.7. A run with no crew set uses it, and so does a shell run.
+
+**At a shell you choose**: `--high` replaces the list, `--low` sets the summaries' models,
+and `--variant` sets the reasoning effort every call asks for.
+
+## What a shell run prints at the end — how long senior-dev ran, what it cost, waiting for the last price, a closed terminal
+
+At a shell, `codeaf senior-dev` first says where it works (`senior-dev · working in
+, on its own branch ` in a repository), then prints each stage, step and
+model call as it happens, then how the run ended, then where its work is (the sentence a
+task's page says), then `the run's record is in` and the run's record folder, and last one
+line with what it came to:
+
+```
+ 277 model calls · $2.30 · 22m 51s
+```
+
+That is the calls, the dollars, and how long senior-dev's own process ran. A figure nobody
+measured is left off, never written as a zero.
+
+When ctrl-c or `--max-cost` stops the run in the middle of a model call, that call is still
+paid for, and its price arrives by a receipt about twenty seconds later. The run waits for
+it before those last lines, and says so on stderr:
+`waiting up to 1m 10s for the price of 1 call that was cut short`. **A second ctrl-c leaves
+at once** instead of waiting; its folder is already finished by then, and only a price
+still owed is missing from the run's line and from this machine's spending ledger.
+
+**A closed terminal or a dropped ssh connection stops the run the way ctrl-c does**:
+senior-dev is stopped and its folder finished. If the codeaf running it is killed
+outright, senior-dev sees within a second that it is gone and stops; its folder is then
+settled by the next run started there, without a commit (see `If codeaf quits while
+senior-dev works`).
+
+Every call is written to this machine's spending ledger, filed as one piece of work named
+after the run's record folder (such as `20260924-150405.000000`). That folder also keeps
+`delegate-program.json`, with the instant senior-dev's process started and the instant it
+ended.
+
+## senior-dev's flags — run, --variant, --in-place, --high, --max-cost
+
+`codeaf senior-dev ` is `codeaf senior-dev run -- `. codeaf gives every
+program it carries four flags:
+
+- `--dir DIR` — the folder to work in (the current one by default; inside a git
+ repository, the repository's root);
+- `--max-cost USD` and `--max-hours H` — the ceilings;
+- `--json` — the program's records on stdout instead of readable lines.
+
+senior-dev's own flags on `run`:
+
+- `--variant NAME` — reasoning effort sent with every call: `low`, `medium`, `high`,
+ `xhigh`; unset leaves the model's own default;
+- `--in-place` — work without git even inside a repository: no commits, and its
+ checkpoints kept outside the folder. A folder with no git history is worked that way
+ without it; codeaf passes it itself under a repository at your home folder;
+- `--high`, `--low` — comma-separated models it routes among; `--low` (its history
+ summaries) falls back to `--high`;
+- `--frontier` — accepted, and changes nothing: no call senior-dev makes uses that tier;
+- `--crew` — the models came from a conversation's crew: one its catalog cannot size is
+ left out instead of failing the run. codeaf passes it with the crew's models.
+
+`codeaf senior-dev help` describes it and its one command, `run`;
+`codeaf senior-dev run --help` prints all of them, codeaf's four included.
+
+## How long did senior-dev take — a run's time, the clock on its page, wall time
+
+A senior-dev run is timed from the moment you handed it off — when its row first reads
+`running`, after its folder is ready and its branch cut — to the moment senior-dev's own
+process ended. Readying the folder before it, and committing what it left after it, are
+not counted. A run whose senior-dev never started is timed to the moment the run ended.
+
+Everything that shows the run's time shows that one span: the line under its page's title
+(counting up from the hand-off while it runs, and stopped at senior-dev's exit once it has
+ended, even before its last changes are committed), its row and card once it has ended, the note the
+conversation is handed when it lands (`done · ran 22m 51s · …`), and the chat's `tasks`
+tool (`#3 · · done · ran 22m 51s · via senior-dev`, or `running for 3m` while it
+goes) — so you can ask the chat how long it took. Each spells it the way the page does — `42s`, `22m 51s`,
+`1h 7m` — except the landed card, which spells it `22m51s`.
+
+The instants senior-dev's process started and ended are also kept in `delegate-program.json`
+in the task's record folder, beside `delegate-stderr.log`.
+
+**After a reopen.** A conversation closed and opened again still shows each run's time, how
+it ended in senior-dev's own words (a `senior-dev did not finish: …` stays that sentence and
+is not turned into a fault), which limit stopped it when one did, `stopped` when you stopped
+it, and the branch its work is on.
+
+**A run nothing is running any more.** If codeaf closed or crashed while senior-dev was
+working, nothing is driving that run: its page reads `incomplete` rather than `running`,
+its time stops at the last thing it did, and it offers no stop.
+
+## Does another conversation or window see my senior-dev run — the @ list, other windows, the project's task list, watching it from another window
+
+Yes. A senior-dev run takes a row in the project's task list the moment it starts, saying
+running, and a second row closes it when it ends, with its time, how it ended, the branch
+its work was kept on and what it cost. So the `@` list, another conversation's `tasks`
+tool, the conversation list's task counts and every other codeaf window on the project
+see it, and a window that has the run's conversation open says it is being worked on. The
+conversation that started the run lists it once, by the number its rail shows.
+
+**Another window can watch it, read-only.** On that window's tasks place the run's row
+stands under `running` with `another window` beside it, and `enter read it as it runs`
+opens the page the conversation that started it shows: senior-dev's actions under their
+steps, the line over them with the step, the spend, the calls and the time, and `ctrl+y`
+for its raw calls. The trail reads `reading in ` and the box says
+`Reading this task… (esc: main)`; `enter` over words answers `this window is reading this
+task — go to the conversation that owns it to steer or stop it`. It offers no stop: only
+the conversation that started the run can stop it. This works where the engine is local,
+as every page read from another window does.
+
+If codeaf went away while the run was working, its row is closed the next time that
+conversation is opened, with the time the run had when it was last seen: it reads `codeaf
+closed while senior-dev was running`, or the run's own ending when it had one. A run
+senior-dev had finished but that codeaf closed under before the run was over reads
+`incomplete — codeaf closed while this was still running`.
+
+## Why did senior-dev stop — how a run ends, its log, crashed or stopped
+
+A run ends in one of these ways, and the task's ending says which:
+
+- `finished: …` — it submitted, and the words after say what the project's build and
+ tests did on the frozen tree;
+- `senior-dev did not finish: …` — it ended without submitting, or what it submitted fails
+ the project's own build or tests. It is not drawn as a fault, what it made is still on its
+ branch, and the chat acts on it (see what codeaf does when senior-dev ends);
+- `senior-dev reached the run's dollar ceiling of $5.00: …` — codeaf refused a model call
+ at the dollar ceiling; the words after are senior-dev's own ending;
+- `senior-dev stopped on its own ceiling: …` — it stopped itself at the time ceiling;
+- `senior-dev crashed: …` — the program itself broke, or could not start (no brief, a
+ refused `senior-dev.json`);
+- `stopped by the run: …` — you, or the run it belonged to, stopped it; what follows is
+ what senior-dev said on its way out, usually `stopped before it finished`;
+- `codeaf closed while senior-dev was running` — the codeaf holding its conversation
+ stopped or crashed while it worked (see the next section);
+- `senior-dev had ended; codeaf closed before it could say where its work is` —
+ senior-dev had already exited, and codeaf stopped before it had finished its folder
+ (see the next section).
+
+When it ends without submitting, it still checks the tree it leaves. If the project's
+tests cannot even start there, the tree is put back to the last state whose build and
+tests could run, or to where it began.
+
+## If codeaf quits while senior-dev works — closed, crashed, engine stopped, restarted mid-run, where is its work
+
+senior-dev ends with the engine holding its conversation. Leaving a hosted conversation's
+window only detaches: senior-dev keeps working. When that engine is stopped or crashes,
+the conversation is closed, or a `--no-host` codeaf quits, the run is over: its page and
+side-list row read `incomplete` with `codeaf closed while senior-dev was running`, no
+stage, nothing waiting on you, and no fault; or `senior-dev had ended; codeaf closed
+before it could say where its work is` if it had exited. A run senior-dev had finished
+reads done, with its result.
+
+**Its folder is settled by the next codeaf that finds the run, and nothing is
+committed**: the one that opens that conversation, hands work off in it, or starts a run
+in that folder, a shell run included. codeaf cannot tell senior-dev's last edits from yours
+made there since, so it commits neither and switches nothing. Its branch stays checked
+out as it was left, its notes are moved out, and the page adds `its work so far is on its
+branch in , which is checked out there, as it left it, with N files not
+committed; commit or stash them there before you go back to your branch `. A run
+started in that folder then is refused over those changes, and adds `they may be an
+earlier senior-dev run's, which codeaf could not finish: its branch is checked
+out there`.
+
+**The run ends where it was last seen working**: senior-dev's exit, or else the end of its
+last model call, its last charge, or its store's last change, whichever is latest. So its
+time and spend do not count the hours codeaf was closed. An orderly close writes the ending
+before senior-dev is stopped; after a crash the next codeaf that opens that conversation,
+or hands work off in it, writes it.
+
+**Nothing carries it on.** The next `/senior-dev` starts a run of its own, with its own
+task and page; the old page stays as the record of what it did.
+
+## senior-dev's log — delegate-stderr.log, agent-summary, a shell run's record folder
+
+Everything senior-dev said while it worked (each stage and what it knew at the time)
+is kept in `delegate-stderr.log` in the task's record folder, and every stage, step and
+ending it reported — what its page draws — in `delegate-actions.jsonl` beside it. Its `agent-summary` there
+adds up each of its agents' calls, time and cost; the cost is the price codeaf's model
+API told it for each call, not a catalog estimate, and a call nobody priced adds nothing. A run started at a shell has
+no task, so its record — that log, its conversation with codeaf, its actions, its stages
+and when it started and ended — is kept in a folder of its own under
+`~/.codeaf/v3/carried/senior-dev/`, one per run.
diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md
index 7fb61956ae..a007213fef 100644
--- a/internal/manual/chat/staying-on-that-machine.md
+++ b/internal/manual/chat/staying-on-that-machine.md
@@ -53,7 +53,7 @@ refused visibly rather than lost.
A recent ssh connection is kept reusable for 300 seconds, so a new channel can avoid a
full handshake when the underlying ssh connection is still healthy. Its control socket
lives under this machine's codeaf state directory at `~/.codeaf/v3/ssh/` (moved by
-`CODEAF_HOME`). The same **104-byte** socket-path limit applies there: a state path too
+`CODEAF_HOME`). The same **103-byte** socket-path limit applies there: a state path too
long disables reuse only; the ordinary ssh connection still opens.
These network-dependent defaults are editable on `/settings`' **Workspace** tab as `ssh
@@ -575,9 +575,10 @@ the one a person really does type; it has its own section above. None of them ap
## Why does codeaf take ten seconds to start, or say the conversation ends with this terminal — a state folder too long for a socket
The thing that holds a conversation after you close the terminal is reached on a unix
-socket under codeaf's own state folder, and a socket path may weigh at most **104
-bytes**. It is 104 rather than Linux's own 108 because the smallest limit is the one that
-travels: macOS stops at 104, and the same folder can be shared over a network mount.
+socket under codeaf's own state folder, and a socket path may weigh at most **103
+bytes**. That is macOS's limit (104 bytes, one of them the end of the name) rather than
+Linux's larger one, because the smallest limit is the one that travels: the same folder
+can be shared over a network mount.
If `CODEAF_HOME` puts that folder deep enough to push the path past the limit, there is
nowhere for a session host to answer, and the launch opens the conversation in this
@@ -586,7 +587,7 @@ under `v3/hosts`. Everything else about the conversation works exactly as it alw
It simply ends when this terminal does. The entry notice says so:
```
-this conversation opened in this terminal instead, and ends with it: codeaf's state folder is a longer path than the 104 bytes a socket may be named in — CODEAF_HOME moves it somewhere shorter
+this conversation opened in this terminal instead, and ends with it: codeaf's state folder is a longer path than the 103 bytes a socket may be named in — CODEAF_HOME moves it somewhere shorter
```
**It used to cost ten seconds.** The launch started a host into a path it could never
@@ -598,7 +599,7 @@ The way out is to point `CODEAF_HOME` at a shorter path — that is the whole of
the next launch holds its conversation in the background again. `codeaf chat --no-host`
is the same floor asked for on purpose, on any machine.
-The same 104 bytes govern the reusable ssh control socket under **How quickly a dead ssh
+The same 103 bytes govern the reusable ssh control socket under **How quickly a dead ssh
link is noticed and retried**: a path past it turns ssh reuse off and nothing else.
## Background replies while another reply finishes
diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md
index a3e89d4a84..053483edf0 100644
--- a/internal/manual/chat/tasks.md
+++ b/internal/manual/chat/tasks.md
@@ -543,7 +543,9 @@ block in the conversation shows:
- one dim sentence under it — the first sentence of the summary, capped at 90 cells, and
left out entirely when it would only repeat the name;
- the facts about the work: which other window is already in these files, `where:` it will
- run, and `from your folder as it stands — unsaved edits included`;
+ run, and `from your folder as it stands — unsaved edits included` (not on a program's
+ card: senior-dev works in the folder itself, and its `where:` names the folder and says
+ `on a branch of its own` in a repository);
- a dim meta line reading `model · ctrl+e for the brief`. The model id leads
because it is the one fact nothing else on screen will say again; on a narrow frame the
hint is dropped and the model kept.
@@ -1201,7 +1203,11 @@ gets dropped. The floor is the words you typed, not how much the reply has alrea
**What still becomes a task.** Several independent pieces in one message, a sweep across
many files, a rewrite you would sit and watch: those can still be handed over, proposed, or
started with `/task`. Typing `/task commit everything` still starts a task, because you
-asked for one.
+asked for one. Asking for a program codeaf carries lifts the floor the same way: for "fix
+this one line with senior-dev", a proposal that hands it to senior-dev is not refused,
+because you asked for senior-dev (the programs page). A name in passing ("fix
+senior-dev's typo in this file") lifts nothing, and nothing lifts it for a commit, an undo
+or a revert: "revert senior-dev's commit" is done here.
## An answer that stops before your question is finished is carried on — my reply stopped halfway, it said it would do the rest and then stopped, codeaf kept going without me
@@ -2725,7 +2731,9 @@ switch, and `tab` comes back.
**`enter read it as it runs`** — the work belongs to a conversation the engine is running
that this window can join. Pressing it opens **that task's own transcript**, live, updating
as the work goes. The trail at the top reads `reading in ` so nothing on
-the page can be mistaken for this conversation's own work. `esc` returns.
+the page can be mistaken for this conversation's own work. `esc` returns. A task handed to
+senior-dev has no transcript, so its page is senior-dev's actions under their steps instead,
+exactly as the conversation that started it shows them, with `ctrl+y` for its raw calls.
This page is **read-only**. The keyboard for that task belongs to the window that owns it,
so the message box says `Reading this task… (esc: main)` and sending anything answers
@@ -5171,8 +5179,10 @@ number its card and the rail show, `#2`; a part the run made for itself is read
place under that task, `#2.1`, `#2.2`, in an order that does not move. A listing shows each
one's name, title, state and the first line of what came back; reading one task shows what
it was asked, what came back in full, what the run's checks found, and its last steps. A
-store's own id is never shown. A finished task is asked about this way and is never redone
-or rechecked by hand.
+task handed to senior-dev also says how long it has taken — `#3 · · done · ran 22m 51s`,
+or `running for 3m` while it goes — and it is read this way whatever the task belt is set
+to. A store's own id is never shown. A finished task is asked about this way and is never
+redone or rechecked by hand.
Tasks from earlier sittings and from other windows are still listed after the run's, and a
number the run does not hold is answered the way it always was.
@@ -5549,7 +5559,10 @@ and breadcrumbs remain available.
## will the chat do it itself or start a task?
One read, one edit or one command the chat does itself. Anything with parts goes
-out as tasks. There is **one way** the chat puts work out, a task:
+out as tasks. Complex coding work a program codeaf carries is for, such as fixing an
+issue in a mature codebase, goes to that program (senior-dev), and so does work you
+name a program for; the programs page says when. There is **one way** the chat puts work
+out, a task:
- **hand off:** the chat proposes a task; approving the card, or letting its
countdown run out, starts it as a run in the conversation's plan.
@@ -5585,3 +5598,11 @@ the run has moved and somebody is looking, rather than every time you look, and
once when the run lands. The **now** sentence also appears under the run's dot
row in the rail, dim and two lines at most. Without a model key the lines are
absent; the task facts remain available on their own.
+
+Each refresh is one model call, and it is counted like any other: it is in the
+conversation's spend on the status line, in `/cost` and in the spending ledger,
+even when its answer could not be used. Two looks at the same moment buy one
+refresh, not two. A run with no rows yet buys none, and neither does a task
+handed to a program such as senior-dev: its page is the actions it took, each
+under the step of its process, and its row already says the step it is in, so it has
+no four lines.
diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md
index e903ddccb9..869e89cbe2 100644
--- a/internal/manual/chat/worker-harness.md
+++ b/internal/manual/chat/worker-harness.md
@@ -27,17 +27,23 @@ says `It joined the work already underway and shares its copy.` A proposed task
ANOTHER folder is refused while that run is underway, with both folders named and
`tasks that run together share one copy of one folder. Propose it again when that work
has ended`. A task handed off after the run has ended starts a run of its own, in a new
-copy cut from your folder as the first run left it. A task handed off in the few seconds
-while a run is finishing (its work landing, its summary being written) waits until that
-run is over and then starts its own: it never joins a run on its way out.
+copy cut from your folder as the first run left it. So does one handed off after a run
+that nothing is driving any more (a limit you set ended it, or codeaf closed under it):
+the old run's store is kept beside the new one as its record — an ordinary run's ended as
+`interrupted`, and a program's run nothing had ended first ended where it was last seen,
+in its own words — and new work never runs inside it. A task handed off in the few
+seconds while a run is finishing (its work landing, its summary being written) waits
+until that run is over and then starts its own: it never joins a run on its way out.
## When does a task run's work come home, including commits its workers made
When a `/task` run on the worker harness ends, its copy's uncommitted work is committed and
merged into the folder it was cut from, the copy is given back, and the run's page
carries `its work is in on `. The conversation is woken with the same
-note a landed task sends: the outcome word, the result the root reported, and where the
-work went (`landed on : N files`, or the sentence saying why it did not). Work
+note a landed task sends: the outcome word, how long the run took (`ran 4m 12s`, from the
+hand-off to the moment its work ended, and left out under a second), the result the root
+reported, and where the work went (`landed on : N files`, or the sentence saying
+why it did not). Work
that will not go in is never forced: the branch is kept in your repository and the note
names it, for example `its branch was kept`, when your checkout moved on after
the copy was cut. A run whose workers committed everything still names its
@@ -48,7 +54,9 @@ landing card says `merged` when the work is in your folder and `branch kept` onl
branch that is waiting. A hand-off that joined the run ends with it: its row settles
`done` or `incomplete` when the run's does. The
row the run was published under settles `done` when the run finished whole and
-`incomplete` on any other ending.
+`incomplete` on any other ending. Each of these rows is in the project's task list (the
+`@` list, other conversations' `tasks` tool, other windows) from the moment it starts, and
+is closed there with its time when it settles.
**With the switch unset, this is the road `/task` takes.** Set
`CODEAF_TASK_BELT=node` to use the older session tree road instead. See *How
@@ -110,7 +118,7 @@ The only difference is where it reads from: a run's task is read from the run's
A page the engine will not answer for — a task this conversation did not spawn, or
one whose store has gone — is not opened; the list stays where it was.
-## Open a run's task from the side list — click its row, or one of its parts
+## Open a run's task from the side list — click its row, or one of its parts, and leave it with esc
With the switch on, a run is drawn in the conversation's side list as its own row,
`#N`, with its parts and their checks hanging under it. **Every one of those rows
@@ -124,7 +132,9 @@ row each, the finished ones included. Every one of those rows is a
door: click the run's row, or select it and press `enter`, and its room opens over the
conversation; click a part's row or a check's row and THAT task's room opens. It is the
room the tasks place opens and the run's tab opens: what the task was asked, its steps,
-its notes, and the box that leaves a note. `esc` goes back to the conversation exactly as
+its notes, and the box that leaves a note. A task handed to a program such as senior-dev
+opens its own program room instead (see *A program's task page is a conversation, not
+steps*). `esc` goes back to the conversation exactly as
you left it, with whatever you had typed still in the box.
The room can take a moment to arrive. From the press on, what you type belongs to the
@@ -138,9 +148,10 @@ every three seconds, so a new step shows within that, and it stops reading when
has settled. A room on a task that has ended is read once, to open it. A step whose
command is many lines long is drawn as its first line and `…`; what ran is unchanged.
-A row the store has no page for opens what it always opened, its room. That is every
-task when the switch is off. A task of an earlier run keeps its page after a later run
-has started.
+A row the store has no page for opens what it always opened, its room: with the switch
+off that is every task, except one handed to a program, which opens the program's room
+with the switch on or off. A task of an earlier run keeps its page after a later run has
+started.
## Can I still read a task from an earlier run?
@@ -258,6 +269,85 @@ room's head says the task is working, how long it has run and what it has cost,
task room's head does. When the command ends the store clears the live step and the next
read draws it as an ordinary call, with what came back behind it.
+## A program's task page is the actions it took, not steps — a delegate's page: open it, leave it, no tab of its own, no note box, what the box says
+
+A task handed to a program codeaf carries (`/ `, such as `/senior-dev`)
+opens **inside the conversation's own tab**, as any task does: from its row on the side
+list, its card in the conversation, a task link, the task strip or the home panel. The
+tab strip stays on top with the conversation's tab the one selected and the `home` tab beside
+it, and the program gets no tab of its own.
+
+```
+ the run ▸ rewrite the auth middleware esc/← main
+─ implement · $1.24 of $5.00 · 3 calls · 14m 3s ─────────────────── Stop ─
+ BRIEF rewrite the auth middleware to use the new session store
+ SETUP set up its workspace git
+ SPEC wrote your brief down as its spec
+ EXPLORE read internal/auth/middleware.go
+ ran go test ./internal/auth/... fails · exit 1
+ IMPLEMENT edited internal/auth/middleware.go
+ ◐ thinking · 12s
+```
+
+`esc`, a press on the conversation's tab and a press on the `home` tab leave it; none of them
+stops the run. `ctrl+o` opens and folds a long brief. `ctrl+y` turns the page to the
+program's raw calls and back. `x` over an empty box, `/stop`, or `Stop` at the end of the
+line over the page asks `Stop this task?` and ends the whole run.
+
+**The box sends nothing.** A program reads no message. The box says ` reads no
+messages — say it to main` (`senior-dev reads no messages — say it to main`), and `enter`
+over a sentence says the same line on the page and leaves your words in the box. Once the
+run has ended its foot and its box say `this task has finished — say it to main`.
+
+In the tasks place, `enter` on the program's row opens the same page as a page of that
+place, with no box at all.
+
+## Reading a program's actions — the step words down the side, how each came out, the call in flight, how long it has run
+
+The page shows what the program did, as the program itself says it: every stage, step and
+ending it reported, kept as codeaf received them, each read in the program's own words.
+The word down the left is the step of the program's own process the action served
+(senior-dev's page has its own section on its steps). It is printed on the first action
+of each run of actions in one step and left blank for the rest, so a word comes back when
+the program comes back to that step. How an action came out is at the right edge, dim:
+`passes`, `fails · exit 2`, `4 files`. Under about 28 cells of room the step's word
+stands on its own line and its actions hang under it.
+
+The page opens on the brief, under `BRIEF`. What only the program's model calls know is
+put in where it happened, each one plain line: `compacted its memory` when the program
+rewrote its history as a summary, `switched to ` when another model started
+answering its work (with the program's reason after it when it gave one), `codeaf
+refused a call · ` and `a call to its model failed · `. A model is named nowhere
+else. While a call is out the last line is `◐ thinking` and its seconds. A long run shows
+its newest actions under a line such as `…142 earlier actions`. A run from before codeaf
+kept a program's actions is drawn from its model calls, each tool asked for as one action
+with no step word, and a long one shows its newest under `…142 earlier calls`.
+
+The line over the page stays put while you scroll: the step the program is in (before it
+names one, its stage in the word it gives a person; the task's own word, such as
+`running` or `done`, when there is neither), what the run has spent (`of` its ceiling when
+the page knows it), how many model calls it has made, and how long it has been going. A
+figure with nothing behind it is left out, and a narrow window drops the time first. The
+time counts from the moment codeaf handed the work over and stops when the program's own
+process ends. The page reads the store again every three seconds while the run works, and
+once more after its work has landed, so the note on where the work went is on the page.
+
+## A program's raw calls — ctrl+y, the dialogue with its model, what it sent and what the model answered
+
+`ctrl+y` on a program's page — in its room or in the tasks place — turns it to the raw
+calls the program made, and `ctrl+y` again turns it back to the actions; the key row says
+which: `ctrl+y calls` or `ctrl+y actions`. A page opens on the actions.
+
+The calls are the conversation between the program and the model that answered it, for
+seeing exactly what it was sent. Each call is the program's side — a tool's result as
+`: `, its own words, or `summarized its history so far` — and the
+model's, named by its short name: the first line of its answer, and one dim row per tool
+it asked for behind that tool's mark. A call codeaf refused is one line from `codeaf`,
+`refused · `; a failed one is `the call failed · `. The call in flight is the
+last line, `◐`, the model and its seconds. Only the first line of each message is drawn,
+and a long run shows its newest calls under a line such as `…142 earlier calls`; the
+task's own record keeps more of every call.
+
## Why is a step missing, the step numbers skip, the cd at the front of a command is gone
**The steps a run's task shows are the work, cut from the commands as they ran.** Two
@@ -502,7 +592,9 @@ what you actually asked for would list every row of the run and never learn it.
Press `x` over an empty box while the run's row is the one task row on the side list,
or open the run's task room and press `x` there over an empty box. Both raise the same
-card, `Stop this task?`, with `stop it` and `keep going`. A digit moves the choice, `enter` takes it, and `esc` is
+card, `Stop this task?`, with `stop it` and `keep going`. On a task handed to a program,
+`x` over an empty box, `/stop`, or `Stop` at the end of the line over its conversation
+raises the same card. A digit moves the choice, `enter` takes it, and `esc` is
`keep going`. Nothing ends on one keystroke. Telling the chat "stop task 1" ends a run
the same way and asks nothing, because your sentence is the decision.
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go
index 67c9cc39e2..d9309e5153 100644
--- a/internal/manual/chat_test.go
+++ b/internal/manual/chat_test.go
@@ -952,6 +952,78 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"how do I stop a run writing outside one folder", "adaptive-runs"},
{"it broke a rule I set", "adaptive-runs"},
{"what is a harness", "saved-shapes-of-work"},
+ {"what is a delegate", "delegates"},
+ {"can you hand this whole task to another coding agent", "delegates"},
+ {"what does the command for a delegate do", "delegates"},
+ {"delegate this to another coding agent", "delegates"},
+ {"why can't the delegate ask me anything", "delegates"},
+ {"why is there no command for my delegate", "delegates"},
+ {"where does a delegate's work go, does it squash the commits", "delegates"},
+ // senior-dev, the program codeaf carries, asked the ways somebody meets
+ // it: what the command does, whether it will stop to ask, where its
+ // commits went, what it cost, its flags, and why a Windows build has none.
+ {"what does /senior-dev do", "senior-dev"},
+ {"will senior-dev stop and ask me questions while it works", "senior-dev"},
+ {"where did senior-dev's commits go", "senior-dev"},
+ {"how much does a senior-dev run cost", "senior-dev"},
+ {"what flags does codeaf senior-dev take", "senior-dev"},
+ {"why is there no /senior-dev on windows", "senior-dev"},
+ {"run senior-dev on a benchmark task from a repository I have not cloned", "senior-dev"},
+ {"can senior-dev work in a folder that is not a git repository", "senior-dev"},
+ {"senior-dev says workspace is not a git repository", "senior-dev"},
+ {"senior-dev finished but its work is not in my folder", "senior-dev"},
+ {"how do I merge senior-dev's branch", "senior-dev"},
+ {"which models does senior-dev use", "senior-dev"},
+ {"make senior-dev use my crew models", "senior-dev"},
+ {"how long did the senior-dev run take", "senior-dev"},
+ {"can a senior-dev task wait for another task to finish first", "senior-dev"},
+ {"retry a senior-dev task that failed", "senior-dev"},
+ {"senior-dev's page still says running after codeaf crashed", "senior-dev"},
+ {"codeaf closed while senior-dev was running where is its work", "senior-dev"},
+ {"my ssh connection dropped during codeaf senior-dev", "senior-dev"},
+ {"can my other window see the senior-dev run", "senior-dev"},
+ {"watch a senior-dev run from another window", "senior-dev"},
+ // Its page is the actions it took, each under the step of its process,
+ // asked the ways somebody watching it would ask.
+ {"what is senior-dev doing", "senior-dev"},
+ {"what do the steps on senior-dev's page mean", "senior-dev"},
+ {"how do I see senior-dev's raw calls to its model", "senior-dev"},
+ // And its badge, asked by somebody who has just seen a bracketed word on a
+ // task and does not know what it is, and by somebody looking for one.
+ {"how do I tell a senior-dev task from a normal task", "senior-dev"},
+ {"what does [senior-dev] mean on a task", "senior-dev"},
+ {"what is the [sd] next to a task on the side list", "senior-dev"},
+ {"which of my tasks are senior-dev's", "senior-dev"},
+ {"does every delegate get its own badge", "delegates"},
+ // And whether codeaf reaches for it on its own, asked the ways somebody
+ // who has just watched it do the work itself, or wants it to, puts it.
+ {"will codeaf use senior-dev by itself", "senior-dev"},
+ {"when does codeaf hand work to senior-dev", "senior-dev"},
+ {"how do I make codeaf use senior-dev", "senior-dev"},
+ {"I asked for senior-dev and it did the work itself", "senior-dev"},
+ {"how do I stop it using senior-dev for this", "senior-dev"},
+ {"will codeaf hand work to a program without being asked", "delegates"},
+ {"is naming a delegate enough to make codeaf use it", "delegates"},
+ {"I typed a correction and it forgot I named the delegate", "delegates"},
+ {"does a correction undo naming a program", "delegates"},
+ {"why did it revert the delegate's commit itself instead of using the delegate", "delegates"},
+ {"which folder does a delegate work in", "delegates"},
+ // A program works in the folder itself, on a branch of its own in a
+ // repository (internal/session's programfolder.go), asked the ways
+ // somebody meets it: where the work went, whether their branch moved,
+ // how to get back, and the refusals that stop a run before it starts.
+ {"where does senior-dev put its work", "senior-dev"},
+ {"does senior-dev change my branch", "senior-dev"},
+ {"how do I go back to my own branch after senior-dev", "senior-dev"},
+ {"senior-dev says my branch moved during the run", "senior-dev"},
+ {"senior-dev refused: changes that are not committed", "senior-dev"},
+ {"senior-dev says my folder is busy", "senior-dev"},
+ {"why can't I run senior-dev in a folder inside the one another run is working in", "senior-dev"},
+ {"can I run senior-dev in a folder that is not a git repo", "senior-dev"},
+ {"where do senior-dev's notes go", "senior-dev"},
+ {"why can't codeaf edit files while senior-dev is working", "senior-dev"},
+ {"my task was refused because senior-dev is working in the folder", "senior-dev"},
+ {"the delegate was refused because of uncommitted changes", "delegates"},
{"the harness I just had built is not in /subharness", "subharnesses"},
{"how do I run a harness I had designed", "subharnesses"},
// The card codeaf raises by itself, asked the three ways somebody meets
@@ -2844,6 +2916,14 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
// watching something the plan does not name is the shape the note was
// written for, and these are the words of a person holding it.
{"my task is waiting on a build outside the plan", "worker-harness"},
+ // A program's task page (internal/tui3's taskconversation.go), asked
+ // the way somebody meets it: a page that is not the list of steps every
+ // other task opens on, and the exchange they are watching on it.
+ {"what is the program saying to the model on its task page", "worker-harness"},
+ {"what does the delegate's task page show", "worker-harness"},
+ {"can I leave a note for the delegate", "worker-harness"},
+ {"what are the words down the side of a program's task page", "worker-harness"},
+ {"what does ctrl+y do on a program's page", "keys"},
// Notes as a channel rather than a log (internal/run's note channel and
// the chat's own `tasks` listing): whether the note was read, when, and
// where anyone else can see it. The first four are the question a person
diff --git a/internal/namelaw/namelaw_test.go b/internal/namelaw/namelaw_test.go
index 88c2659d57..733c6ec4d6 100644
--- a/internal/namelaw/namelaw_test.go
+++ b/internal/namelaw/namelaw_test.go
@@ -226,7 +226,7 @@ func TestW7ThePromptNamesCodeafOnceAndNamesNoRetiredProduct(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- wantFiles := []string{"bashrules.md", "bashtask.md", "bashworker.md", "discipline.md", "divide.md", "fanout.md", "landing-answer.md", "quick.md", "revise.md", "runask.md", "runsummary.md", "shape.md", "system.md", "worker.md"}
+ wantFiles := []string{"bashrules.md", "bashtask.md", "bashworker.md", "discipline.md", "divide.md", "fanout.md", "landing-answer.md", "program-outcome.md", "quick.md", "revise.md", "runask.md", "runsummary.md", "shape.md", "system.md", "worker.md"}
var gotFiles []string
var corpus []byte
for _, entry := range entries {
diff --git a/internal/plandb/stoproot_test.go b/internal/plandb/stoproot_test.go
index 21420796d4..41ecea7bd5 100644
--- a/internal/plandb/stoproot_test.go
+++ b/internal/plandb/stoproot_test.go
@@ -3,6 +3,7 @@ package plandb
import (
"path/filepath"
"testing"
+ "time"
)
// A PERSON'S STOP ENDS THE WHOLE RUN IN THE STORE, AND KEEPS WHAT HAD LANDED.
@@ -50,6 +51,102 @@ func TestStopRootEndsTheRunAndEverythingStillOpenUnderIt(t *testing.T) {
}
}
+// A RUN WHOSE OWN TASK FAILED IS OVER IN THE STORE. Nothing wrote its ending,
+// so it read as running for ever and the next hand-off would have adopted it.
+// The runtime's verb fails the run's task with the reason, cancels what is
+// still open, writes no result, and leaves what had ended as it ended.
+func TestFailRootEndsTheRunWithoutAResult(t *testing.T) {
+ store := planOpen(t, filepath.Join(t.TempDir(), "plan.json"))
+ planAdd(t, store, planSpec("landed", "Landed"), planSpec("waiting", "Waiting"))
+ planFinish(t, store, "landed", "worker", "landed delivered")
+
+ if err := store.FailRoot("senior-dev did not finish: its tests fail"); err != nil {
+ t.Fatalf("fail root: %v", err)
+ }
+ root := store.Task("root")
+ if root.Status != StatusFailed || root.Error != "senior-dev did not finish: its tests fail" || root.Result != "" || root.CompletedAt.IsZero() {
+ t.Fatalf("the run's own task after it failed = %s, %q, result %q, ended %v", root.Status, root.Error, root.Result, root.CompletedAt)
+ }
+ if task := store.Task("waiting"); task.Status != StatusCancelled {
+ t.Fatalf("open work under a failed run = %s, want cancelled", task.Status)
+ }
+ if task := store.Task("landed"); task.Status != StatusDone || task.Result != "landed delivered" {
+ t.Fatalf("work that had already landed was rewritten: %s, %q", task.Status, task.Result)
+ }
+ if err := store.FailRoot("again"); err != nil || store.Task("root").Error != "senior-dev did not finish: its tests fail" {
+ t.Fatalf("a second ending rewrote the first: %v, %q", err, store.Task("root").Error)
+ }
+}
+
+// A RUN WHOSE PROCESS WENT AWAY IS ENDED WHEN IT WAS LAST SEEN. The next
+// process to find its store open ends it at the instant it names, so the run's
+// page does not count the hours nobody was driving it; an instant before the
+// run began or after now is held inside what can be true of the run.
+func TestFailRootAtEndsTheRunAtTheInstantItNames(t *testing.T) {
+ store := planOpen(t, filepath.Join(t.TempDir(), "plan.json"))
+ // The run's clock starts where its own task was made, so every instant
+ // below is one the run could have lived through.
+ clock := store.Task("root").CreatedAt
+ store.now = func() time.Time { return clock }
+ planAdd(t, store, planSpec("waiting", "Waiting"))
+
+ lastSeen := clock
+ clock = clock.Add(11 * time.Hour)
+ if err := store.FailRootAt("codeaf closed while senior-dev was running", lastSeen.Add(29*time.Minute)); err != nil {
+ t.Fatalf("fail root at: %v", err)
+ }
+ ended := store.Task("root")
+ if ended.Status != StatusFailed || ended.Error != "codeaf closed while senior-dev was running" {
+ t.Fatalf("the run's own task = %s (%q), want failed with the sentence", ended.Status, ended.Error)
+ }
+ if want := lastSeen.Add(29 * time.Minute); !ended.CompletedAt.Equal(want) || !ended.UpdatedAt.Equal(want) {
+ t.Fatalf("the run ended at %v (updated %v), want the instant it was last seen, %v", ended.CompletedAt, ended.UpdatedAt, want)
+ }
+ if task := store.Task("waiting"); task.Status != StatusCancelled || !task.CompletedAt.Equal(lastSeen.Add(29*time.Minute)) {
+ t.Fatalf("open work under the run = %s ended %v, want cancelled with the run", task.Status, task.CompletedAt)
+ }
+
+ // Held inside the run's own life: never before it began, never after now.
+ early := planOpen(t, filepath.Join(t.TempDir(), "plan.json"))
+ early.now = func() time.Time { return clock }
+ began := early.Task("root").CreatedAt
+ if err := early.FailRootAt("gone", began.Add(-time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if got := early.Task("root").CompletedAt; !got.Equal(began) {
+ t.Fatalf("an ending before the run began was written at %v, want its start %v", got, began)
+ }
+ late := planOpen(t, filepath.Join(t.TempDir(), "plan.json"))
+ late.now = func() time.Time { return clock }
+ if err := late.FailRootAt("gone", clock.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if got := late.Task("root").CompletedAt; !got.Equal(clock) {
+ t.Fatalf("an ending in the future was written at %v, want now %v", got, clock)
+ }
+}
+
+// The ledger's latest charge is the last moment a run was certainly spending,
+// and a ledger with none answers nothing rather than a zero-cost instant.
+func TestLastSpendAtIsTheLedgersLatestCharge(t *testing.T) {
+ clock := time.Date(2026, time.September, 24, 1, 14, 6, 5e8, time.UTC)
+ store := planOpen(t, filepath.Join(t.TempDir(), "plan.json"))
+ store.now = func() time.Time { return clock }
+ if got := store.LastSpendAt(); !got.IsZero() {
+ t.Fatalf("a ledger with no charge answered %v", got)
+ }
+ for _, step := range []time.Duration{0, 9 * time.Second, 3 * time.Second} {
+ clock = clock.Add(step)
+ if err := store.AddSpend("root", "delegate/senior-dev", "work", 0.01, 10, 2); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want := time.Date(2026, time.September, 24, 1, 14, 18, 5e8, time.UTC)
+ if got := store.LastSpendAt(); !got.Equal(want) {
+ t.Fatalf("the latest charge = %v, want %v", got, want)
+ }
+}
+
// A RUN THAT ENDS ON ITS OWN LIMIT OR ITS OWN WORKER IS ENDED IN THE STORE,
// AND NOT AS A PERSON'S STOP. The run's own task is failed with the reason,
// what was still open is cancelled under the same reason, what had landed keeps
diff --git a/internal/plandb/store.go b/internal/plandb/store.go
index 6342c81d14..b54b2167b9 100644
--- a/internal/plandb/store.go
+++ b/internal/plandb/store.go
@@ -1530,11 +1530,12 @@ func (s *Store) CompleteRoot(result string) error {
// a cascade that follows cancelled parents stops at a parent that ended earlier
// and would leave the open work under it to be offered to the next worker.
//
-// A RUN LEFT OPEN IS A RUN THE NEXT HAND-OFF ADOPTS, which is why a stop has to
-// be written here and cannot only be a context somebody cut: a store whose run
-// task is still open is picked up again by the next run over it, stopped work
-// included. Two presses are one stop, and a run that ended by itself is left as
-// it ended.
+// A RUN LEFT OPEN READS AS RUNNING, which is why a stop has to be written here
+// and cannot only be a context somebody cut: a store whose run task is still
+// open is drawn as work going, and a door that adopts open stores (the
+// headless errand's, the carry-on door) picks it up again, stopped work
+// included. Two presses are one stop, and a run that ended by itself is left
+// as it ended.
func (s *Store) StopRoot(reason string) error {
return s.closeRoot(StatusCancelled, reason)
}
@@ -1587,6 +1588,73 @@ func (s *Store) closeRoot(rootStatus Status, reason string) error {
})
}
+// FailRoot ends the run because the run's own task failed: its worker came
+// home with an error and nothing of the run is still working. Only the runtime
+// calls it, the way only the runtime calls [Store.CompleteRoot] and
+// [Store.StopRoot]. The run's task is failed with the reason, and every other
+// task still open is cancelled with it, in one transaction; a task that had
+// already ended keeps its ending. No result is written: a result is what a
+// finished run delivers, and a failed worker's account is not one.
+//
+// A FAILED RUN WAS LEFT OPEN, AND AN OPEN RUN READS AS RUNNING. Nothing wrote
+// the ending of a run whose own worker failed, so its store said `running` for
+// ever: the task's page drew `running` and offered `stop it` over a program
+// that had ended forty minutes earlier, and a door that adopts open stores
+// would have taken the dead run up as live work ([Store.StopRoot]). A run that
+// already ended is left as it ended.
+func (s *Store) FailRoot(reason string) error {
+ return s.FailRootAt(reason, time.Time{})
+}
+
+// FailRootAt is [Store.FailRoot] with the instant the run ended named rather
+// than read off the clock: the zero time is now, which is FailRoot itself.
+//
+// A RUN WHOSE PROCESS WENT AWAY ENDED WHEN IT WAS LAST SEEN, NOT WHEN SOMEBODY
+// NOTICED. A program's run that codeaf was closed under is ended by the next
+// process that finds its store open, which can be hours later; written at that
+// moment, the run's page counted every hour the machine sat idle as time the
+// program had worked. The caller names the run's last evidence of life instead
+// (its last model call, its last charge, the store's own last write), and the
+// ending is written there.
+//
+// The instant is held inside what can be true of the run: never before its own
+// task was made, because a run cannot end before it began, and never after
+// now, because an ending in the future would read as a run still going.
+func (s *Store) FailRootAt(reason string, at time.Time) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.transact(func(next *state, now time.Time) error {
+ root := next.Tasks[next.RootID]
+ if root == nil || terminal(root.Status) {
+ return errNoChange
+ }
+ ended := now
+ if !at.IsZero() && at.Before(now) {
+ ended = at.UTC()
+ }
+ if ended.Before(root.CreatedAt) {
+ ended = root.CreatedAt
+ }
+ reason = strings.TrimSpace(reason)
+ for _, task := range next.Tasks {
+ if terminal(task.Status) || task.ID == root.ID {
+ continue
+ }
+ task.Status, task.Error, task.ClaimedBy = StatusCancelled, reason, ""
+ task.Owner, task.SeenAt = "", time.Time{}
+ task.UpdatedAt, task.CompletedAt = ended, ended
+ if ended.Before(task.CreatedAt) {
+ task.UpdatedAt, task.CompletedAt = task.CreatedAt, task.CreatedAt
+ }
+ }
+ root.Status, root.Error, root.ClaimedBy = StatusFailed, reason, ""
+ root.Owner, root.SeenAt = "", time.Time{}
+ root.UpdatedAt, root.CompletedAt = ended, ended
+ promote(next, ended)
+ return nil
+ })
+}
+
// Archive moves whole finished subtrees out of the live plan and into the
// archive: a task and every task under it, when each one has been terminal —
// done, cancelled or failed — for longer than the window. The moved tasks
@@ -2969,6 +3037,43 @@ func (s *Store) SpendBy(axis string, since time.Time) []SpendLine {
return lines
}
+// LastSpendAt answers when the ledger's latest charge was written, and the
+// zero time for a ledger with none or a store that is closed. It is one of the
+// three readings a run's last evidence of life is taken from, beside its last
+// model call and the store's own last write ([Store.FailRootAt] says why that
+// instant matters): a charge is written the moment a call was paid for, so it
+// is the latest moment the run was certainly still spending.
+//
+// THE LATEST IS FOUND IN GO, not with MAX() in the query, for the reason
+// SpendBy gives: `at` is RFC3339Nano text, whose fractional digits vary, so a
+// text comparison would misorder a whole second against its own fraction.
+func (s *Store) LastSpendAt() time.Time {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return time.Time{}
+ }
+ rows, err := s.rdb.Query(`SELECT at FROM spend`)
+ if err != nil {
+ return time.Time{}
+ }
+ defer rows.Close()
+ var latest time.Time
+ for rows.Next() {
+ var at string
+ if err := rows.Scan(&at); err != nil {
+ return time.Time{}
+ }
+ if moment, err := parseTime(at); err == nil && moment.After(latest) {
+ latest = moment
+ }
+ }
+ if rows.Err() != nil {
+ return time.Time{}
+ }
+ return latest
+}
+
func cloneTask(task *Task) *Task {
if task == nil {
return nil
diff --git a/internal/provider/billing.go b/internal/provider/billing.go
index 01277d3305..feee7921e6 100644
--- a/internal/provider/billing.go
+++ b/internal/provider/billing.go
@@ -94,8 +94,25 @@ type Reconciled struct {
// finish without their usage blocks at the same instant.
type ReconcileSink func(Reconciled)
+// ReceiptPending is told the moment a receipt is queued for a call whose
+// stream ended without its usage block, and answers the function to call once
+// that receipt's one answer has reached the [ReconcileSink]. The answer is
+// called exactly once, found or not, so a count kept with it always comes back
+// to zero.
+//
+// IT EXISTS FOR WORK WHOSE BOOKS CLOSE. A receipt is fetched in the background
+// on a schedule that runs for seconds after the call returned, and a caller
+// that reads its total and closes its books the moment its last call ends
+// reads a total without that money — the stopped senior-dev runs of
+// 2026-09-23 lost their in-flight call exactly so, about twenty seconds before
+// its receipt arrived. With this armed, such a caller can wait (bounded by
+// [ReceiptWait]) for what it is still owed before it reads the total.
+type ReceiptPending func() (done func())
+
type billingContextKey struct{}
type reconcileContextKey struct{}
+type receiptPendingContextKey struct{}
+type unmeteredReceiptsContextKey struct{}
// WithBilling arms one piece of work's banking. Like the transcript sink it
// belongs to the work rather than to the client, because one client serves
@@ -117,6 +134,51 @@ func WithReconcile(ctx context.Context, sink ReconcileSink) context.Context {
return context.WithValue(ctx, reconcileContextKey{}, sink)
}
+// WithReceiptPending arms one piece of work to be told about every receipt
+// queued on its behalf and when each was answered ([ReceiptPending]). It
+// changes nothing about how a receipt is fetched or banked: the money still
+// reaches the work through [WithReconcile] alone.
+func WithReceiptPending(ctx context.Context, pending ReceiptPending) context.Context {
+ if pending == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, receiptPendingContextKey{}, pending)
+}
+
+// WithUnmeteredReceipts arms one piece of work to have an answer that arrived
+// whole but carried no usage block settled the way a cut one is ([Client.settle]):
+// its receipt is asked for by generation id, or it is told as a call nobody
+// could price. Without it such an answer is billed nowhere and said nowhere,
+// which is every other caller's behaviour, left alone on purpose.
+//
+// IT IS OPT-IN BECAUSE IT IS NEW MONEY ON AN OLD ROAD. A program's model API
+// arms it (internal/provider/modelapi): its runs are held to a dollar ceiling
+// and read as one account, and true-myth's call 7483768e of 2026-09-23 — a 200
+// on kimi-k2.6 after nearly eight seconds with no usage block — was in no book
+// at all. A direct service is untouched either way, because settle stops at
+// one: its missing usage block is a subscription's silence, not a charge.
+func WithUnmeteredReceipts(ctx context.Context) context.Context {
+ return context.WithValue(ctx, unmeteredReceiptsContextKey{}, true)
+}
+
+// unmeteredReceiptsFrom reports whether [WithUnmeteredReceipts] armed ctx.
+func unmeteredReceiptsFrom(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ armed, _ := ctx.Value(unmeteredReceiptsContextKey{}).(bool)
+ return armed
+}
+
+// receiptPendingFrom reads back what [WithReceiptPending] armed, or nil.
+func receiptPendingFrom(ctx context.Context) ReceiptPending {
+ if ctx == nil {
+ return nil
+ }
+ pending, _ := ctx.Value(receiptPendingContextKey{}).(ReceiptPending)
+ return pending
+}
+
// billingFrom reads back the sink WithBilling armed, or nil.
func billingFrom(ctx context.Context) BillingSink {
if ctx == nil {
@@ -170,6 +232,17 @@ func (c *Client) bill(ctx context.Context, model string, response *ai.Response)
sink(billed)
}
+// billAnswered bills an answer that arrived whole. One with no usage block is
+// settled like a cut one when the work asked for that ([WithUnmeteredReceipts]),
+// and billed the ordinary way — which banks nothing for it — otherwise.
+func (c *Client) billAnswered(ctx context.Context, model string, response *ai.Response, answerBytes int) {
+ if response != nil && response.Usage == nil && unmeteredReceiptsFrom(ctx) {
+ c.settle(ctx, model, response, receiptUnmeteredReason, answerBytes)
+ return
+ }
+ c.bill(ctx, model, response)
+}
+
// BillingSinkFrom and CallNodeFrom read back what a leaf's context was armed
// with. They exist for the surfaces that arm it and the tests that check they
// did: arming billing is one line at three call sites, and a call site that
@@ -179,5 +252,12 @@ func BillingSinkFrom(ctx context.Context) BillingSink { return billingFrom(ctx)
// ReconcileSinkFrom reads back the receipt sink [WithReconcile] armed, or nil.
func ReconcileSinkFrom(ctx context.Context) ReconcileSink { return reconcileFrom(ctx) }
+// UnmeteredReceiptsFrom reports whether [WithUnmeteredReceipts] armed ctx.
+func UnmeteredReceiptsFrom(ctx context.Context) bool { return unmeteredReceiptsFrom(ctx) }
+
+// ReceiptPendingFrom reads back what [WithReceiptPending] armed, or nil — for a
+// scripted funnel that owes a receipt the way the provider's own does.
+func ReceiptPendingFrom(ctx context.Context) ReceiptPending { return receiptPendingFrom(ctx) }
+
// CallNodeFrom is the node WithCallNode named, empty when nothing did.
func CallNodeFrom(ctx context.Context) string { return callNode(ctx) }
diff --git a/internal/provider/client.go b/internal/provider/client.go
index a97ea9cdc1..75df588dfc 100644
--- a/internal/provider/client.go
+++ b/internal/provider/client.go
@@ -1101,7 +1101,12 @@ func (c *Client) completionInOnePiece(
began: logBegan, status: status, served: served, err: cut,
responseBody: payload,
})
- c.bill(ctx, c.modelFor(request), &response)
+ // THROUGH THE ANSWERED DOOR, not the bare one: the provider charged for
+ // this 200 whether or not its text was language, and on work that asked
+ // for it an answer with no usage block is priced by its receipt
+ // ([Client.billAnswered]). The bare door banks nothing without usage, so
+ // that charge reached no book at all.
+ c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response)))
return nil, false, cut
}
reasonWord, servedWell := answerOutcome(&response)
@@ -1124,7 +1129,7 @@ func (c *Client) completionInOnePiece(
})
// The money, banked at the same instant the log row is written and for the
// same reason: this is where the fact is known. See billing.go.
- c.bill(ctx, c.modelFor(request), &response)
+ c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response)))
return &response, len(relearned) > 0, nil
}
@@ -2165,7 +2170,8 @@ func (c *Client) completeWithMessagesStreaming(
response: response, reasoningTokens: reasoningTokens,
ttft: firstTokenAfter(began, firstToken),
})
- c.bill(ctx, c.modelFor(request), response)
+ // Through the answered door, for the reason the whole-body twin gives.
+ c.billAnswered(ctx, c.modelFor(request), response, content.Len())
return nil, false, cut
}
// A RESCUE IS NOT THE TURN UNTIL IT READS AS LANGUAGE. The hedge used
@@ -2179,7 +2185,9 @@ func (c *Client) completeWithMessagesStreaming(
response: response, reasoningTokens: reasoningTokens,
ttft: firstTokenAfter(began, firstToken),
})
- c.bill(ctx, c.modelFor(request), response)
+ // A rescue that is not language was still paid for, so it goes through
+ // the answered door too.
+ c.billAnswered(ctx, c.modelFor(request), response, content.Len())
return nil, false, cut
}
// PAST EVERY GUARD, SO THIS LANE SERVED — the recovery half of the quality
@@ -2209,7 +2217,7 @@ func (c *Client) completeWithMessagesStreaming(
// Both paths or neither, exactly as the learning above: a streamed answer
// is billed by the provider the same way a whole-body one is, and a ledger
// blind to one of the two transports is a ledger nobody can reconcile.
- c.bill(ctx, c.modelFor(request), response)
+ c.billAnswered(ctx, c.modelFor(request), response, content.Len())
finished = true
observer(StreamEvent{Kind: StreamFinished, Session: session})
return response, relearned, nil
diff --git a/internal/provider/modelapi/export_test.go b/internal/provider/modelapi/export_test.go
new file mode 100644
index 0000000000..5a74963268
--- /dev/null
+++ b/internal/provider/modelapi/export_test.go
@@ -0,0 +1,11 @@
+package modelapi
+
+import "time"
+
+// ShortenReceiptWait lets a test outside the package see Close give up on a
+// receipt that never comes without waiting the provider's whole schedule.
+func ShortenReceiptWait(bound time.Duration) (restore func()) {
+ was := receiptWait
+ receiptWait = bound
+ return func() { receiptWait = was }
+}
diff --git a/internal/provider/modelapi/resolve.go b/internal/provider/modelapi/resolve.go
new file mode 100644
index 0000000000..d1e0194b1f
--- /dev/null
+++ b/internal/provider/modelapi/resolve.go
@@ -0,0 +1,98 @@
+package modelapi
+
+// Which model answers a program's call.
+//
+// A program asks for the models of its own pool — senior-dev names DeepSeek,
+// Qwen, Kimi, GLM and MiniMax ids, sometimes behind an `openrouter/` prefix —
+// and it was written for a machine that has an OpenRouter account. The person
+// running it may not have one: their profile can reach models only through a
+// service of their own (a local proxy, a vendor's key), which knows none of
+// those ids. A program is not a person who can be asked to pick again, so the
+// answer is decided here, once per call, by one rule.
+//
+// THE RULE: HONOUR THE ASK, OR ANSWER WITH THE RUN'S OWN SEAT, AND SAY WHICH.
+// The model the program asked for is used whenever one of this person's
+// services can take a call on it. When none can, the call is answered on the
+// run's work seat — the model a task's own worker would sit on in this run —
+// and the turn the conversation log keeps says so in its Served field. A call
+// is NEVER refused only because this machine does not know the id it named:
+// failing a whole task over a spelling the person never chose is the wrong
+// trade, and the seat is a model they did choose.
+//
+// WHETHER A SERVICE CAN TAKE A CALL IS NOT DECIDED HERE. It is the account
+// pool's own question (internal/session's ServesModel, the same test the
+// conversation's client pool asks before it seats a model), handed in by the
+// caller as a function, so the model API and the conversation cannot come to
+// two answers about one machine.
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// Resolve is THE ONE PLACE a program's model becomes the model that answers
+// it. asked is the id as the program wrote it, serves answers whether one of
+// this person's services can take a call on a model (nil answers yes for
+// every model), and seats are the models a call falls to, in order — the run's
+// work seat first.
+//
+// model is what the call goes out as; served is set exactly when it is not
+// the model that was asked for, and it is what [delegate.Turn.Served] carries.
+// A seat that cannot be served either is passed over for the next; when
+// nothing can be served the call goes out as asked, so the funnel's own road
+// answers it and says why, rather than this function inventing a refusal.
+func Resolve(asked string, serves func(model string) bool, seats ...string) (model, served string) {
+ asked = strings.TrimSpace(asked)
+ can := func(candidate string) bool { return serves == nil || serves(candidate) }
+ if asked != "" && can(asked) {
+ return asked, ""
+ }
+ for _, seat := range seats {
+ seat = strings.TrimSpace(seat)
+ if seat == "" || seat == asked {
+ continue
+ }
+ if can(seat) {
+ return seat, seat
+ }
+ }
+ if asked == "" {
+ // A call that named no model at all is answered on the first seat
+ // there is, whatever can be said about it: there is nothing else to
+ // send, and the funnel says why if it cannot.
+ for _, seat := range seats {
+ if seat = strings.TrimSpace(seat); seat != "" {
+ return seat, seat
+ }
+ }
+ }
+ return asked, ""
+}
+
+// unknownHere reports that a call failed because this machine could not serve
+// the model it went out as — no key for the service the id resolves to, or a
+// router that carries no such model — which is the one failure the seat can
+// cure. It reads the funnel's own typed facts, never its sentence.
+func unknownHere(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, provider.ErrNoAPIKey) {
+ return true
+ }
+ refusal, ok := provider.RefusalFrom(err)
+ return ok && refusal.Withdrawn
+}
+
+// without is serves with one model struck out: the funnel has just said it
+// cannot serve it, whatever the account pool believed a moment ago.
+func without(serves func(string) bool, gone string) func(string) bool {
+ return func(model string) bool {
+ if strings.TrimSpace(model) == strings.TrimSpace(gone) {
+ return false
+ }
+ return serves == nil || serves(model)
+ }
+}
diff --git a/internal/provider/modelapi/resolve_test.go b/internal/provider/modelapi/resolve_test.go
new file mode 100644
index 0000000000..12d9ce39a3
--- /dev/null
+++ b/internal/provider/modelapi/resolve_test.go
@@ -0,0 +1,76 @@
+package modelapi_test
+
+import (
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/modelsource"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// routerAccount is a machine with an OpenRouter key and nothing else.
+func routerAccount() modelsource.Set {
+ source := modelsource.DefaultSource("https://openrouter.ai/api/v1")
+ return modelsource.NewSet(modelsource.Connected{Source: source, Key: "sk-or-v1-routerkey0000000000", Address: source.Address})
+}
+
+// proxyOnly is the owner's machine: no OpenRouter key, and one service of
+// their own — an OpenAI-compatible proxy on this machine — that carries every
+// conversation.
+func proxyOnly() modelsource.Set {
+ router := modelsource.DefaultSource("https://openrouter.ai/api/v1")
+ proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"}
+ return modelsource.NewSet(
+ modelsource.Connected{Source: router, Address: router.Address},
+ modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address},
+ )
+}
+
+// servedBy is the account pool's own test over one machine's services — the
+// door the model API is handed in production (session.ServesModel), never a
+// second copy of it.
+func servedBy(sources modelsource.Set) func(string) bool {
+ return func(model string) bool { return session.ServesModel(sources, model) }
+}
+
+// THE RULE, ON THE MACHINES IT IS FOR: a model the person's services carry is
+// honoured as asked; one they cannot reach is answered on the run's seat and
+// the answer names the seat; an `openrouter/` prefix is read as the service it
+// names, never as part of the model; and nothing is refused here only because
+// this machine does not know the id.
+func TestResolveHonoursACarriedModelAndSeatsTheRest(t *testing.T) {
+ const seat = "mybox/qwen3-coder"
+ for _, row := range []struct {
+ name string
+ sources modelsource.Set
+ asked string
+ seats []string
+ model, served string
+ }{
+ {"a model the router carries", routerAccount(), "deepseek/deepseek-v4-flash-0731", []string{seat},
+ "deepseek/deepseek-v4-flash-0731", ""},
+ {"a prefixed id on the router's own key", routerAccount(), "openrouter/deepseek/deepseek-v4-pro", []string{seat},
+ "openrouter/deepseek/deepseek-v4-pro", ""},
+ {"a model no service here can reach", proxyOnly(), "moonshotai/kimi-k2.6", []string{seat},
+ seat, seat},
+ {"a prefixed id whose service has no key", proxyOnly(), "openrouter/z-ai/glm-5.1", []string{seat},
+ seat, seat},
+ {"a model the person's own service carries", proxyOnly(), "mybox/deepseek-v4-flash", []string{seat},
+ "mybox/deepseek-v4-flash", ""},
+ {"a seat nothing can reach is passed over for the next", proxyOnly(), "qwen/qwen3.6-plus", []string{"deepseek/deepseek-v4-pro", seat},
+ seat, seat},
+ {"nothing here can serve anything named", proxyOnly(), "minimax/minimax-m2.7", []string{"z-ai/glm-5.1"},
+ "minimax/minimax-m2.7", ""},
+ {"a call that named no model", routerAccount(), "", []string{"deepseek/deepseek-v4-flash-0731"},
+ "deepseek/deepseek-v4-flash-0731", "deepseek/deepseek-v4-flash-0731"},
+ } {
+ model, served := modelapi.Resolve(row.asked, servedBy(row.sources), row.seats...)
+ if model != row.model || served != row.served {
+ t.Errorf("%s: Resolve(%q) = %q served %q, want %q served %q", row.name, row.asked, model, served, row.model, row.served)
+ }
+ }
+ // With nobody to ask, every model is taken as written.
+ if model, served := modelapi.Resolve("anything/at-all", nil, seat); model != "anything/at-all" || served != "" {
+ t.Fatalf("Resolve with no door = %q %q", model, served)
+ }
+}
diff --git a/internal/provider/modelapi/route.go b/internal/provider/modelapi/route.go
new file mode 100644
index 0000000000..a123e0eca1
--- /dev/null
+++ b/internal/provider/modelapi/route.go
@@ -0,0 +1,23 @@
+// Package modelapi is the model API codeaf serves each run of a program it
+// carries (internal/delegate): an OpenAI-style chat-completions endpoint on
+// this machine, opened by one token, whose every call goes through codeaf's own
+// model funnel — refused at the run's ceiling, priced, logged, and written down
+// as one turn of the program's conversation with codeaf.
+//
+// IT LIVES UNDER internal/provider BECAUSE THAT IS THE ONLY PLACE A MODEL
+// ROUTE MAY BE SPELLED (funnel_law_test.go). A program in codeaf's own tree
+// builds its request URL with [ChatURL] rather than appending the route
+// itself, so the route is written once, here, and the law holds for the
+// program's code as for everything else.
+package modelapi
+
+import "strings"
+
+// chatRoute is the one route a program calls, relative to the API's base URL.
+const chatRoute = "/chat/completions"
+
+// ChatURL is the chat-completions endpoint of an API whose base URL is base,
+// the way every OpenAI client joins them: one slash between.
+func ChatURL(base string) string {
+ return strings.TrimRight(strings.TrimSpace(base), "/") + chatRoute
+}
diff --git a/internal/provider/modelapi/route_test.go b/internal/provider/modelapi/route_test.go
new file mode 100644
index 0000000000..7c73aae033
--- /dev/null
+++ b/internal/provider/modelapi/route_test.go
@@ -0,0 +1,11 @@
+package modelapi
+
+import "testing"
+
+func TestChatURLJoinsTheBaseAndTheRouteWithOneSlash(t *testing.T) {
+ for _, base := range []string{"http://127.0.0.1:9/v1", "http://127.0.0.1:9/v1/", " http://127.0.0.1:9/v1 "} {
+ if got := ChatURL(base); got != "http://127.0.0.1:9/v1/chat/completions" {
+ t.Fatalf("ChatURL(%q) = %q", base, got)
+ }
+ }
+}
diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go
new file mode 100644
index 0000000000..028658d71f
--- /dev/null
+++ b/internal/provider/modelapi/server.go
@@ -0,0 +1,1112 @@
+package modelapi
+
+// The server: one per run of a program, on this machine's loopback, opened by
+// one token, closed when the run ends.
+//
+// ── EVERY CALL IS A TURN OF A CONVERSATION A PERSON CAN READ ────────────────
+//
+// To the program this is a model backend like any other. To codeaf the program
+// is a very particular person asking it things, so every call is written down
+// as one turn of that conversation (delegate.Turn, in the task's own record
+// folder): once when it starts, so the task page can show a call in flight, and
+// once when it ends, under the same number.
+//
+// ── MONEY IS METERED HERE, CALL BY CALL, AND NOWHERE ELSE ───────────────────
+//
+// The funnel tells whoever armed the call what each answer cost the moment it
+// decodes it (provider.WithBilling), and a receipt fetched later for a stream
+// that was cut before its usage block (provider.WithReconcile). Both reach the
+// run through [Config.Bank] as they happen, so the run's ceiling, the task's
+// spend rows and the machine's spending ledger all see a call's dollars before
+// the program does. The program's own account of what it spent is never read.
+//
+// ── THE RUN'S BOOKS CLOSE ONLY WHEN ITS RECEIPTS ARE IN ─────────────────────
+//
+// A call cut in the middle — the one in flight when a person stops the run, or
+// when the supervisor ends it at the ceiling — is still paid for, and its price
+// arrives by the provider's receipt about twenty seconds later. [Server.Close]
+// waits for every receipt still owed on this run's calls (bounded by
+// provider.ReceiptWait) before it returns, so the run's total read after it,
+// the task's spend rows and the conversation's books all hold that money.
+//
+// ── THE CEILING IS A REFUSAL BEFORE THE CALL ────────────────────────────────
+//
+// A call made once the run's metered spend has reached its dollar ceiling is
+// never made: it is answered 402 in the router's own shape and written down as
+// a refused turn. A call already in flight when the ceiling is crossed is not
+// cut here — the run's supervisor ends the program for that, the way it ends
+// any worker whose run has spent its allowance.
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/effort"
+ "github.com/Agent-Field/codeaf/internal/guard"
+ lanes "github.com/Agent-Field/codeaf/internal/lane"
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// Completer is the funnel one call goes out through. It is provider.Client's
+// own method, and internal/session's Completer is the same one method, so a
+// run hands this the conversation's completer as it is and a test hands it a
+// script.
+type Completer interface {
+ CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error)
+}
+
+// Charge is one priced answer, as the funnel billed it.
+type Charge struct {
+ // Model is the model that answered, in the funnel's own spelling.
+ Model string
+ TokensIn int
+ TokensOut int
+ Cached int
+ CostUSD float64
+ // Spent is the run's metered total with this charge in it. It only rises,
+ // and charges are told one at a time in the order they were metered, so a
+ // bank that keeps the latest figure is always right.
+ Spent float64
+ // Late says the charge is a receipt the provider fetched after its call had
+ // already returned — a stream cut before its usage block.
+ Late bool
+}
+
+// Config is one run's API.
+type Config struct {
+ // TaskDir is the task's record folder, where the conversation log is kept
+ // (delegate.ConversationFile). Empty keeps no log.
+ TaskDir string
+ // CompleterFor answers the funnel a call on model goes out through. Nil is
+ // a run with no model road: every call is answered with the sentence that
+ // says so, and none is made.
+ CompleterFor func(model string) Completer
+ // Serves answers whether one of this person's services can take a call on
+ // model — the account pool's own test, handed in (internal/session's
+ // ServesModel). Nil answers yes for every model.
+ Serves func(model string) bool
+ // Seat is the run's own work seat: the model a call falls to when the one
+ // the program asked for cannot be served on this machine ([Resolve]).
+ Seat string
+ // Ceiling is the run's dollar ceiling, zero for none.
+ Ceiling float64
+ // Bank is told every charge as it is metered. It is called one charge at a
+ // time and must not block on the program.
+ Bank func(Charge)
+ // Unbilled is told a call the provider charged for and could put no figure
+ // on — a cut stream whose receipt never came.
+ Unbilled func(model string)
+ // Settling is told, once, when [Server.Close] begins to wait for the
+ // receipts still owed on this run's cut calls, with how many there are, so
+ // a person watching the run can be told why its end takes a moment. Nil
+ // says nothing; nothing is told when nothing is owed.
+ Settling func(owed int)
+ // Role is the lane role the calls ride: an unattended leaf when nobody is
+ // reading, which is a run's worker, and an attended one for a shell run a
+ // person is watching. Empty is unattended.
+ Role lanes.Role
+ // Node names the work the calls belong to in the model-call log — the
+ // program's name — so `codeaf logs --node ` reads one program's calls.
+ // Their tag is `task`, the word every call made inside a piece of work
+ // carries (internal/session's purposeTask).
+ Node string
+ // Keepalive overrides [DefaultKeepalive], for a test that must not wait
+ // fifteen seconds to see one.
+ Keepalive time.Duration
+}
+
+// DefaultKeepalive is how often a waiting answer says it is still coming. It
+// is well inside the two-minute idle timeout a program's HTTP client keeps, so
+// a model thinking for half an hour never looks like a dead connection.
+const DefaultKeepalive = 15 * time.Second
+
+// basePath is the version segment every OpenAI-style base URL ends in, and the
+// route is appended to it exactly as a client appends it ([ChatURL]).
+const basePath = "/v1"
+
+// closeWait bounds how long [Server.Close] waits for calls already in flight
+// to write their last record. Their contexts are ended first, so an honest
+// funnel returns at once; the bound is for one that does not.
+const closeWait = 10 * time.Second
+
+// Server is one run's model API.
+type Server struct {
+ config Config
+ listener net.Listener
+ server *http.Server
+ base string
+ // ctx ends every call in flight when the run's API closes.
+ ctx context.Context
+ cancel context.CancelFunc
+ calls sync.WaitGroup
+
+ // mu guards the token, the ending, the meter, the turn numbers and the
+ // threads' memory — everything a call reads and writes that another call
+ // may be reading at the same moment.
+ mu sync.Mutex
+ token string
+ closed bool
+ spent float64
+ seq int
+ threads threads
+ // refused counts the calls answered 402 at the ceiling.
+ refused int
+
+ // bankMu keeps charges in the order they were metered, one at a time, and
+ // logMu keeps two turns from sharing one write of the log.
+ bankMu sync.Mutex
+ logMu sync.Mutex
+
+ // owed counts the receipts the funnel has queued for this run's calls and
+ // not yet answered (provider.WithReceiptPending), which [Server.Close]
+ // waits for.
+ owed receiptsOwed
+}
+
+// Open starts one run's API on an OS-chosen 127.0.0.1 port and mints its
+// token.
+//
+// 127.0.0.1 AND NEVER 0.0.0.0, for the file door's reason: the token is the
+// only thing between a caller and the person's model account, and a listener
+// on every interface hands that account to anybody on the same network who
+// can guess a port.
+func Open(config Config) (*Server, error) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, fmt.Errorf("modelapi: %w", err)
+ }
+ token, err := mint(32)
+ if err != nil {
+ _ = listener.Close()
+ return nil, fmt.Errorf("modelapi: mint the run's token: %w", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ s := &Server{config: config, listener: listener, token: token, ctx: ctx, cancel: cancel,
+ base: "http://" + listener.Addr().String() + basePath}
+ mux := http.NewServeMux()
+ mux.HandleFunc(basePath+chatRoute, s.serveChat)
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ writeError(w, http.StatusNotFound, "the model API answers "+basePath+chatRoute+" and nothing else")
+ })
+ s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+ guard.Go("modelapi/serve", func() { _ = s.server.Serve(listener) })
+ return s, nil
+}
+
+// API is the address and the token a program is started with
+// (delegate.ChildEnv). After [Server.Close] the token is empty: a closed API
+// has nothing to hand out.
+func (s *Server) API() delegate.ModelAPI {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return delegate.ModelAPI{BaseURL: s.base, Token: s.token}
+}
+
+// Spent is the run's metered total so far.
+func (s *Server) Spent() float64 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.spent
+}
+
+// RefusedAtCeiling is how many calls were refused because the run's dollar
+// ceiling had been reached.
+//
+// IT IS WHAT TELLS THE CEILING FROM A CRASH. A program that budgets by its own
+// sum of each answer's cost can be refused before that sum reaches the
+// ceiling it was given — codeaf's meter counts every answer the funnel was
+// charged for, retries included — and senior-dev then ends its run as
+// `crashed`. The run was stopped by the limit a person set, and the worker
+// reads this to say so (internal/run's DelegateWorker).
+func (s *Server) RefusedAtCeiling() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.refused
+}
+
+// refuse counts one call refused at the ceiling.
+func (s *Server) refuse() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.refused++
+}
+
+// Close ends the API: THE TOKEN DIES WITH THE RUN. The token is forgotten,
+// every call in flight is ended, the listener and every connection are closed,
+// and the calls that were running are given [closeWait] to write their last
+// record. A grandchild the program left behind can no longer spend.
+//
+// AND THE RUN'S BOOKS ARE CLOSED WITH EVERY RECEIPT IN THEM. A call cut in
+// the middle — ended just now by this very close, or by the program's own stop
+// a moment before — is priced by a receipt the provider fetches in the
+// background about twenty seconds later. Close waits for every receipt still
+// owed, for at most [receiptWait], so the [Server.Spent] a caller reads after
+// it is the run's whole total and every charge has reached [Config.Bank] while
+// the caller's books are still open. It measured: each of the three stopped
+// runs of 2026-09-23 lost exactly that call from its task, its run total and
+// its conversation's books, and only the machine's ledger heard of it.
+func (s *Server) Close() error {
+ if !s.end() {
+ return nil
+ }
+ s.cancel()
+ err := s.server.Close()
+ drained := make(chan struct{})
+ guard.Go("modelapi/close", func() {
+ s.calls.Wait()
+ close(drained)
+ })
+ select {
+ case <-drained:
+ case <-time.After(closeWait):
+ }
+ if owed := s.owed.count(); owed > 0 && s.config.Settling != nil {
+ s.config.Settling(owed)
+ }
+ s.owed.wait(receiptWait)
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return err
+}
+
+// receiptWait bounds how long [Server.Close] waits for the receipts owed on a
+// run's cut calls: the provider's own ceiling for one receipt, so a receipt
+// the provider is still asking for is never abandoned early, and one that will
+// never come costs the run's ending no more than that. The provider counts that
+// ceiling from the instant a receipt was queued, however many were ahead of it
+// for a worker, and every receipt owed by a call that had ended by the time
+// this wait began was queued before it, so one bound covers them all. A
+// variable only so a test can shorten it.
+var receiptWait = provider.ReceiptWait
+
+// receiptsOwed counts receipts queued and not yet answered. Its idle channel
+// is closed whenever the count is zero and made anew when it leaves zero, so a
+// waiter can wait on it with a bound and look again when it closes.
+type receiptsOwed struct {
+ mu sync.Mutex
+ n int
+ idle chan struct{}
+}
+
+// owe counts one receipt in and answers the function that counts it out,
+// which does so once however often it is called.
+func (o *receiptsOwed) owe() func() {
+ o.add()
+ var once sync.Once
+ return func() { once.Do(o.settle) }
+}
+
+// add counts one receipt in, making the idle channel anew when the count
+// leaves zero.
+func (o *receiptsOwed) add() {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.n == 0 {
+ o.idle = make(chan struct{})
+ }
+ o.n++
+}
+
+// settle counts one receipt out, closing the idle channel when none is left.
+func (o *receiptsOwed) settle() {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ o.n--
+ if o.n == 0 {
+ close(o.idle)
+ }
+}
+
+// count is how many receipts are owed now.
+func (o *receiptsOwed) count() int {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ return o.n
+}
+
+// owing answers the channel that closes when nothing is owed, or nil when
+// nothing is owed now.
+func (o *receiptsOwed) owing() chan struct{} {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.n == 0 {
+ return nil
+ }
+ return o.idle
+}
+
+// wait returns when nothing is owed, or when bound has passed; it answers
+// whether everything owed came in.
+func (o *receiptsOwed) wait(bound time.Duration) bool {
+ deadline := time.Now().Add(bound)
+ for {
+ idle := o.owing()
+ if idle == nil {
+ return true
+ }
+ left := time.Until(deadline)
+ if left <= 0 {
+ return false
+ }
+ timer := time.NewTimer(left)
+ select {
+ case <-idle:
+ timer.Stop()
+ case <-timer.C:
+ return false
+ }
+ }
+}
+
+// end marks the API closed and forgets its token, and answers whether this was
+// the call that closed it.
+func (s *Server) end() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return false
+ }
+ s.closed, s.token = true, ""
+ return true
+}
+
+// enter counts one call in, unless the API has closed. It is taken under the
+// same lock Close sets the ending under, so no call is counted after Close has
+// begun to wait.
+func (s *Server) enter() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return false
+ }
+ s.calls.Add(1)
+ return true
+}
+
+// authorized reports whether a request carries this run's token. The compare
+// takes the same time whatever the guess, so a token cannot be read off how
+// long a refusal takes.
+func (s *Server) authorized(r *http.Request) bool {
+ token := s.liveToken()
+ if token == "" {
+ return false
+ }
+ given, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
+ if !ok {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(given)), []byte(token)) == 1
+}
+
+// liveToken is the run's token, empty once the API has closed.
+func (s *Server) liveToken() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return ""
+ }
+ return s.token
+}
+
+// serveChat is the one route.
+func (s *Server) serveChat(w http.ResponseWriter, r *http.Request) {
+ if !s.authorized(r) {
+ writeError(w, http.StatusUnauthorized, "that token does not open this run's model API")
+ return
+ }
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", http.MethodPost)
+ writeError(w, http.StatusMethodNotAllowed, "the model API answers POST")
+ return
+ }
+ if !s.enter() {
+ writeError(w, http.StatusServiceUnavailable, "this run has ended")
+ return
+ }
+ defer s.calls.Done()
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes))
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("the request is larger than the %d bytes one call may carry", maxRequestBytes))
+ return
+ }
+ writeError(w, http.StatusBadRequest, "the request body could not be read")
+ return
+ }
+ request, err := decodeRequest(body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ // A PROGRAM MAY NAME ITS LINEAGE IN A HEADER ONLY. The router's
+ // session-affinity header is the same fact as the body's prompt_cache_key —
+ // which warm instance this conversation belongs on — and codeaf's adapter
+ // sends its own header from the key it is handed, so a header with no key
+ // beside it is read as the key.
+ if request.cacheKey == "" {
+ if affinity := strings.TrimSpace(r.Header.Get(affinityHeader)); affinity != "" {
+ request.cacheKey, request.thread = affinity, affinity
+ }
+ }
+ s.serve(w, r, request)
+}
+
+// affinityHeader is the router's session-affinity header, which a program
+// written for OpenRouter sends beside its prompt_cache_key.
+const affinityHeader = "X-Session-Affinity"
+
+// record is one call's turn as it stands, shared with the receipt that can
+// arrive after the call has ended.
+type record struct {
+ mu sync.Mutex
+ turn delegate.Turn
+ ended bool
+}
+
+// serveOn says the call went out on the seat instead of the ask.
+func (r *record) serveOn(seat string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.turn.Served = seat
+}
+
+// close writes the call's ending onto its turn and answers the turn as it now
+// stands, for the log.
+func (r *record) close(fill func(turn *delegate.Turn)) delegate.Turn {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.turn.Ended = time.Now()
+ fill(&r.turn)
+ r.ended = true
+ return r.turn
+}
+
+// open numbers one call, opens its turn with what the thread had not said
+// before, names the working the program handed back by the field its thread's
+// working last arrived on, and answers the run's spend at the moment the call
+// arrived — the figure its ceiling is asked against.
+func (s *Server) open(request *call, thread, served string) (*record, float64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.seq++
+ entry := &record{turn: delegate.Turn{Seq: s.seq, Thread: thread, Started: time.Now(), Model: request.asked, Served: served}}
+ entry.turn.Sent, entry.turn.Restarted = s.threads.delta(thread, request.messages)
+ request.reasoning = s.threads.name(thread, request.reasoning)
+ return entry, s.spent
+}
+
+// arrived remembers the field a thread's working came in on.
+func (s *Server) arrived(thread, field string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.threads.arrived(thread, field)
+}
+
+// serve answers one decoded call: the model decided, the turn opened, the
+// ceiling asked, the funnel called with keepalives while it thinks, the model
+// fallen back to the seat when the machine could not serve the ask, and the
+// answer written in the shape the call asked for.
+func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) {
+ model, served := Resolve(request.asked, s.config.Serves, s.config.Seat)
+ thread := request.thread
+ if thread == "" {
+ thread = delegate.MainThread
+ }
+ entry, spent := s.open(request, thread, served)
+
+ if ceiling := s.config.Ceiling; ceiling > 0 && ceilingReached(ceiling, spent) {
+ // 402 AND NOTHING THAT READS AS PASSING: a program's client retries a
+ // 408, a 409, a 429 and a 5xx as the weather, and a ceiling is not
+ // weather — asked again it answers the same.
+ s.refuse()
+ refused := ceilingSentence(ceiling, spent)
+ s.log(entry.close(func(turn *delegate.Turn) { turn.Refused = refused }))
+ writeError(w, http.StatusPaymentRequired, refused)
+ return
+ }
+ s.log(entry.turn)
+
+ ctx, stop := s.callContext(r.Context())
+ defer stop()
+ out := &reply{w: w, stream: request.stream, id: "gen-" + mustMint(12), created: time.Now().Unix()}
+ bill := &tally{}
+ catch := &catcher{}
+ slot := &provider.ServedEndpoint{}
+ response, err := s.complete(ctx, out, request, model, bill, catch, slot, entry)
+ // THE ONE FAILURE THE SEAT CAN CURE: the machine could not serve the model
+ // the program asked for — no key for its service, or a router that carries
+ // no such model — though the account pool believed it could. The call goes
+ // out once more, on the seat, and the turn says so.
+ if err != nil && served == "" && ctx.Err() == nil && unknownHere(err) {
+ if fallback, seat := Resolve(request.asked, without(s.config.Serves, model), s.config.Seat); seat != "" {
+ model = fallback
+ entry.serveOn(seat)
+ catch = &catcher{}
+ response, err = s.complete(ctx, out, request, model, bill, catch, slot, entry)
+ }
+ }
+
+ var said answer
+ status, sentence := 0, ""
+ if err != nil {
+ status, sentence = s.failure(err, r.Context(), model)
+ } else {
+ said = answerOf(response, model, bill, catch, slot, out)
+ s.arrived(thread, said.reasoning.field)
+ }
+ s.log(entry.close(func(turn *delegate.Turn) {
+ turn.TokensIn, turn.TokensOut, turn.Cached, turn.CostUSD = bill.figures()
+ turn.Failed = sentence
+ if err == nil {
+ turn.Reply, turn.Calls = said.text, toolUses(said.calls)
+ }
+ turn.Served = answeredBy(*turn, bill.model(), response)
+ }))
+
+ if r.Context().Err() != nil {
+ // The program stopped waiting; there is nobody to write the answer to.
+ return
+ }
+ if err != nil {
+ out.fail(status, sentence, model)
+ return
+ }
+ out.answer(said)
+}
+
+// complete makes one call through the funnel and waits for it, saying the
+// answer is still coming every [Config.Keepalive] while it does.
+func (s *Server) complete(ctx context.Context, out *reply, request *call, model string, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record) (*ai.Response, error) {
+ var completer Completer
+ if s.config.CompleterFor != nil {
+ completer = s.config.CompleterFor(model)
+ }
+ if completer == nil {
+ return nil, errNoRoad
+ }
+ options := append(append([]ai.Option(nil), request.options...), ai.WithModel(model))
+ ctx = s.settings(ctx, request, bill, catch, slot, entry)
+ type outcome struct {
+ response *ai.Response
+ err error
+ }
+ done := make(chan outcome, 1)
+ guard.Go("modelapi/call", func() {
+ // THE ANSWER IS SENT ON EVERY PATH, a fault included: the handler is
+ // waiting on this channel, and a funnel that panicked must come back as
+ // a failed call rather than a handler that waits for ever.
+ result := outcome{err: errFault}
+ defer func() { done <- result }()
+ result.response, result.err = completer.CompleteWithMessages(ctx, request.messages, options...)
+ })
+ interval := s.config.Keepalive
+ if interval <= 0 {
+ interval = DefaultKeepalive
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case result := <-done:
+ if result.err == nil && result.response == nil {
+ return nil, errEmpty
+ }
+ return result.response, result.err
+ case <-ticker.C:
+ out.keepalive()
+ }
+ }
+}
+
+// settings are the per-call facts the funnel reads off the call's context:
+// who the call is for, what it is called in the log, the program's own cache
+// lineage and reasoning depth, the working it handed back, the sinks that
+// meter it, catch its working and name its server, the count of the receipts
+// it is owed, and the ask that an answer with no usage block be priced too.
+func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record) context.Context {
+ role := s.config.Role
+ if role == "" {
+ role = lanes.RoleLeafUnattended
+ }
+ ctx = provider.WithRole(ctx, role)
+ ctx = provider.WithCallTag(ctx, "task")
+ ctx = provider.WithCallNode(ctx, s.config.Node)
+ ctx = provider.WithCacheKey(ctx, request.cacheKey)
+ // THE PROGRAM ASKED FOR ITS DEPTH IN SO MANY WORDS, which is what a person's
+ // configured level is: sent even to a model the catalog cannot vouch for,
+ // and dropped by the adapter's own repair if the model refuses it. A rung
+ // rides the ladder's one translation (xhigh and max as a thinking budget);
+ // the two words that are not rungs ride the adapter's own.
+ switch {
+ case request.depth.rung != effort.None:
+ ctx = provider.WithConfiguredEffortRung(ctx, request.depth.rung)
+ case request.depth.word != provider.EffortNone:
+ ctx = provider.WithConfiguredReasoningEffort(ctx, request.depth.word)
+ }
+ ctx = provider.WithMessageReasoning(ctx, request.reasoning)
+ ctx = provider.WithBilling(ctx, func(billed provider.Billed) { s.charge(bill, billed, false) })
+ ctx = provider.WithReconcile(ctx, func(receipt provider.Reconciled) { s.receipt(bill, entry, receipt) })
+ ctx = provider.WithReceiptPending(ctx, s.owed.owe)
+ // AN ANSWER WITH NO USAGE BLOCK IS NOT A FREE ONE. The funnel asks for a
+ // receipt only when an answer was cut; one that arrived whole and simply
+ // carried no usage block was billed nowhere and said so nowhere —
+ // true-myth's call 7483768e on 2026-09-23, a 200 on kimi-k2.6 after nearly
+ // eight seconds with no figure in any book. A run's calls are settled like
+ // cut ones instead: priced by their receipt, or told as calls nobody could
+ // price, never guessed.
+ ctx = provider.WithUnmeteredReceipts(ctx)
+ ctx = provider.WithStreamObserver(ctx, catch.observe)
+ return provider.WithServedEndpoint(ctx, slot)
+}
+
+// callContext is the call's own context: the request's, which ends when the
+// program stops waiting, ended as well when the run's API closes.
+func (s *Server) callContext(parent context.Context) (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(parent)
+ unhook := context.AfterFunc(s.ctx, cancel)
+ return ctx, func() {
+ unhook()
+ cancel()
+ }
+}
+
+// charge meters one billed answer: onto the call's own tally, onto the run's
+// total, and to the bank, one charge at a time.
+func (s *Server) charge(bill *tally, billed provider.Billed, late bool) {
+ if billed.Empty() {
+ return
+ }
+ bill.add(billed)
+ s.bankMu.Lock()
+ defer s.bankMu.Unlock()
+ spent := s.meter(billed.Cost)
+ if s.config.Bank != nil {
+ s.config.Bank(Charge{
+ Model: strings.TrimSpace(billed.Model), TokensIn: billed.PromptTokens, TokensOut: billed.CompletionTokens,
+ Cached: billed.CachedTokens, CostUSD: billed.Cost, Spent: spent, Late: late,
+ })
+ }
+}
+
+// meter adds one charge to the run's total and answers the total.
+func (s *Server) meter(cost float64) float64 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.spent += cost
+ return s.spent
+}
+
+// receipt is a cut stream's late answer. A found receipt is the same real
+// money and is metered like any charge, and the call's turn is written again
+// with it, so the page's figure for that call is the true one; a receipt that
+// never came is told as a call nobody could price.
+func (s *Server) receipt(bill *tally, entry *record, receipt provider.Reconciled) {
+ if !receipt.Found || receipt.Billed.Empty() {
+ if s.config.Unbilled != nil {
+ s.config.Unbilled(strings.TrimSpace(receipt.Model))
+ }
+ return
+ }
+ s.charge(bill, receipt.Billed, true)
+ entry.mu.Lock()
+ defer entry.mu.Unlock()
+ if !entry.ended {
+ // The call has not written its ending yet; the tally it reads carries
+ // this receipt already.
+ return
+ }
+ entry.turn.TokensIn, entry.turn.TokensOut, entry.turn.Cached, entry.turn.CostUSD = bill.figures()
+ entry.turn.Served = answeredBy(entry.turn, bill.model(), nil)
+ s.log(entry.turn)
+}
+
+// answeredBy is the model a turn names as the one that answered: the one it
+// already names — the run's seat, when [Resolve] or the funnel put the call
+// there — or else the model the funnel billed, or the answer's own model when
+// nothing was billed, whenever that is another model than the one asked for.
+//
+// THE PAGE MUST NOT NAME A MODEL THAT NEVER ANSWERED. When nothing this
+// machine serves can take the ask or the seat, the call goes out as asked, and
+// the conversation's own pool may put it on the model in its own seat — which
+// this API never hears of. Run 3d6d on 2026-09-24 asked qwen, deepseek and
+// kimi 27 times, and gpt-5.6-sol answered every one; the page named the three
+// that never did. The funnel's bill names who answered, so it is the witness.
+func answeredBy(turn delegate.Turn, billed string, response *ai.Response) string {
+ if served := strings.TrimSpace(turn.Served); served != "" {
+ return served
+ }
+ answered := strings.TrimSpace(billed)
+ if answered == "" && response != nil {
+ answered = strings.TrimSpace(response.Model)
+ }
+ if answered == "" || sameModel(turn.Model, answered) {
+ return ""
+ }
+ return answered
+}
+
+// sameModel reports whether two ids name one model, read the way the task page
+// names a speaker: by the part after the last vendor, case aside, so
+// `openrouter/deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-pro` are one
+// model, and a dated build of it (`deepseek-v4-pro-0731`) or a variant of it
+// (`qwen3.6-plus:free`) is still it ([buildOf] says which suffixes count). An
+// ask that named no model is never the same as the model that answered it.
+func sameModel(asked, answered string) bool {
+ word := func(id string) string {
+ id = strings.ToLower(strings.TrimSpace(id))
+ if at := strings.LastIndexByte(id, '/'); at >= 0 {
+ id = id[at+1:]
+ }
+ return id
+ }
+ a, b := word(asked), word(answered)
+ if a == "" || b == "" {
+ return a == b
+ }
+ return a == b || buildOf(a, b) || buildOf(b, a)
+}
+
+// buildOf reports whether long is short with a build stamp or a variant on it:
+// `-0731` or `-20260731` (four to eight digits, which is how makers date a
+// build), a `:free` variant, or a dated build of a variant.
+//
+// A HYPHEN FOLLOWED BY A WORD IS ANOTHER MODEL. `gpt-5.5-mini`,
+// `deepseek-v4-flash` and `kimi-k2-thinking` are siblings of the model they
+// extend, sold and priced as models of their own, and reading any hyphenated
+// suffix as a build named the ask as the speaker when its sibling answered —
+// the misnaming [answeredBy] exists to stop.
+func buildOf(long, short string) bool {
+ rest, ok := strings.CutPrefix(long, short)
+ if !ok {
+ return false
+ }
+ if variant, ok := strings.CutPrefix(rest, ":"); ok {
+ return variant != ""
+ }
+ stamp, ok := strings.CutPrefix(rest, "-")
+ if !ok {
+ return false
+ }
+ stamp, variant, tagged := strings.Cut(stamp, ":")
+ if tagged && variant == "" {
+ return false
+ }
+ return len(stamp) >= 4 && len(stamp) <= 8 && strings.Trim(stamp, "0123456789") == ""
+}
+
+// log writes one turn. A log that cannot be written costs the record and never
+// the call: the program is owed its answer whatever the disk does.
+func (s *Server) log(turn delegate.Turn) {
+ if strings.TrimSpace(s.config.TaskDir) == "" {
+ return
+ }
+ s.logMu.Lock()
+ defer s.logMu.Unlock()
+ _ = delegate.AppendTurn(s.config.TaskDir, turn)
+}
+
+// ── the call's own figures ──────────────────────────────────────────────────
+
+// tally is what one call cost, across every answer the funnel was charged for
+// on its way to the one it returned.
+type tally struct {
+ mu sync.Mutex
+ in, out int
+ cached int
+ cost float64
+ billed bool
+ lastModel string
+}
+
+func (t *tally) add(billed provider.Billed) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.in += billed.PromptTokens
+ t.out += billed.CompletionTokens
+ t.cached += billed.CachedTokens
+ t.cost += billed.Cost
+ t.billed = true
+ if model := strings.TrimSpace(billed.Model); model != "" {
+ t.lastModel = model
+ }
+}
+
+// model is the model the funnel last billed for this call, empty when it
+// billed nothing.
+func (t *tally) model() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.lastModel
+}
+
+func (t *tally) figures() (in, out, cached int, cost float64) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.in, t.out, t.cached, t.cost
+}
+
+// usage is the call's usage block: the metered figures when the funnel billed
+// anything, and the answer's own usage block otherwise.
+func (t *tally) usage(response *ai.Response) usageBlock {
+ block, billed := t.metered()
+ if !billed && response != nil && response.Usage != nil {
+ block = usageBlock{
+ PromptTokens: response.Usage.PromptTokens, CompletionTokens: response.Usage.CompletionTokens,
+ PromptTokensDetails: promptDetail{CachedTokens: response.Usage.CacheReadTokens()},
+ }
+ if response.Usage.Cost != nil {
+ block.Cost = *response.Usage.Cost
+ }
+ }
+ block.TotalTokens = block.PromptTokens + block.CompletionTokens
+ return block
+}
+
+// metered is the tally as a usage block, and whether the funnel billed
+// anything at all.
+func (t *tally) metered() (usageBlock, bool) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return usageBlock{PromptTokens: t.in, CompletionTokens: t.out, Cost: t.cost, PromptTokensDetails: promptDetail{CachedTokens: t.cached}}, t.billed
+}
+
+// answerOf is the funnel's response as the program is handed it.
+func answerOf(response *ai.Response, model string, bill *tally, catch *catcher, slot *provider.ServedEndpoint, out *reply) answer {
+ said := answer{
+ id: out.id, provider: slot.Name(), model: model, created: out.created,
+ text: response.Text(), calls: response.ToolCalls(), finish: finishOf(response),
+ reasoning: catch.caught(), usage: bill.usage(response),
+ }
+ if answered := strings.TrimSpace(response.Model); answered != "" {
+ said.model = answered
+ }
+ return said
+}
+
+// toolUses is the answer's tool calls as the log writes them.
+func toolUses(calls []ai.ToolCall) []delegate.ToolUse {
+ var uses []delegate.ToolUse
+ for _, call := range calls {
+ uses = append(uses, delegate.ToolUse{Name: call.Function.Name, Args: call.Function.Arguments})
+ }
+ return uses
+}
+
+// ── failures ────────────────────────────────────────────────────────────────
+
+var (
+ // errNoRoad is a run started with no funnel at all.
+ errNoRoad = errors.New("this run was started with no road to a model")
+ // errFault is a funnel that panicked; the fault itself is in the log guard
+ // writes.
+ errFault = errors.New("the model road failed inside codeaf")
+ // errEmpty is a funnel that answered nothing and said nothing.
+ errEmpty = errors.New("the model road answered nothing")
+)
+
+// failure is one failed call's status and sentence, in the words the program
+// is answered with and the turn is written with.
+//
+// AN ACCOUNT REFUSED UPSTREAM IS NOT THE PROGRAM'S TOKEN BEING WRONG. A 401 or
+// 403 from the model's service is codeaf's own account being refused, and on
+// this API those two statuses mean the run's token; the program is told 502,
+// a gateway whose far side said no, with the far side's sentence.
+func (s *Server) failure(err error, request context.Context, model string) (int, string) {
+ switch {
+ case s.ctx.Err() != nil:
+ return http.StatusServiceUnavailable, "the run ended before the answer came back"
+ case request.Err() != nil:
+ return 499, "the program stopped waiting for the answer"
+ case errors.Is(err, errNoRoad):
+ return http.StatusServiceUnavailable, err.Error()
+ case errors.Is(err, provider.ErrNoAPIKey):
+ return http.StatusServiceUnavailable, "no service on this machine can answer " + quoted(model) + ": it has no key for the service that model is on"
+ case errors.Is(err, context.DeadlineExceeded):
+ return http.StatusGatewayTimeout, firstLine(err.Error())
+ }
+ if refusal, ok := provider.RefusalFrom(err); ok {
+ status := refusal.Status
+ if status == http.StatusUnauthorized || status == http.StatusForbidden || status < 400 || status > 599 {
+ status = http.StatusBadGateway
+ }
+ return status, firstLine(refusal.Error())
+ }
+ return http.StatusBadGateway, firstLine(err.Error())
+}
+
+// ceilingSentence is the refusal a call made past the run's ceiling gets.
+func ceilingSentence(ceiling, spent float64) string {
+ return "the run's dollar ceiling of " + dollars(ceiling) + " is reached (" + dollars(spent) + " spent), so codeaf made no call"
+}
+
+// ceilingDust is the most a ceiling may still have left and be reached: a
+// billionth of a dollar, far below any call's price and far above the float
+// rounding in a sum of prices.
+const ceilingDust = 1e-9
+
+// ceilingReached reports whether a run's spend has reached its ceiling.
+//
+// A CEILING WITH NOTHING LEFT IS REACHED BEFORE THE FIRST CALL. A run whose
+// person's limit was already spent is handed the smallest positive figure,
+// because zero means no ceiling at all (internal/session's runCostLeft); read
+// as `spent >= ceiling`, nothing spent was still under it, and the run's first
+// call was made and paid for. Nothing left is nothing left.
+func ceilingReached(ceiling, spent float64) bool {
+ return ceiling-spent <= ceilingDust
+}
+
+// dollars writes an amount the way a person reads one: cents, and four places
+// under a cent so a small run is not written as nothing. An amount that would
+// still read as nothing at four places — a ceiling with nothing left — is
+// written as the nothing it is.
+func dollars(amount float64) string {
+ if amount >= 0.00005 && amount < 0.01 {
+ return fmt.Sprintf("$%.4f", amount)
+ }
+ return fmt.Sprintf("$%.2f", amount)
+}
+
+func quoted(model string) string {
+ if strings.TrimSpace(model) == "" {
+ return "the default model"
+ }
+ return model
+}
+
+// firstLine is an error's first line, because a refusal is one sentence.
+func firstLine(text string) string {
+ line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
+ return line
+}
+
+// writeError answers a call that has not begun its reply, in the router's own
+// error envelope.
+func writeError(w http.ResponseWriter, status int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(errorBody{Error: errorDetail{Message: message, Code: status}})
+}
+
+// ── the reply ───────────────────────────────────────────────────────────────
+
+// reply is one call's side of the response: nothing is written until the
+// answer is ready or the first keepalive is due, so a call that fails fast is
+// answered with its real status; after that the status is 200 and a failure
+// travels in the body, the way the router sends one.
+type reply struct {
+ w http.ResponseWriter
+ stream bool
+ id string
+ created int64
+ committed bool
+}
+
+func (r *reply) commit() {
+ if r.committed {
+ return
+ }
+ header := r.w.Header()
+ if r.stream {
+ header.Set("Content-Type", "text/event-stream")
+ header.Set("Cache-Control", "no-cache")
+ } else {
+ header.Set("Content-Type", "application/json")
+ }
+ r.w.WriteHeader(http.StatusOK)
+ r.committed = true
+}
+
+// keepalive says the answer is still coming: an event-stream comment on a
+// stream, and on a whole body the whitespace JSON allows before its value, so
+// a client's idle timer is fed either way.
+func (r *reply) keepalive() {
+ r.commit()
+ if r.stream {
+ _, _ = io.WriteString(r.w, ": keepalive\n\n")
+ } else {
+ _, _ = io.WriteString(r.w, "\n")
+ }
+ r.flush()
+}
+
+// answer writes the finished answer in the shape the call asked for.
+func (r *reply) answer(said answer) {
+ r.commit()
+ if !r.stream {
+ _ = json.NewEncoder(r.w).Encode(said.whole())
+ r.flush()
+ return
+ }
+ for _, event := range said.chunks() {
+ r.event(event)
+ }
+ _, _ = io.WriteString(r.w, "data: [DONE]\n\n")
+ r.flush()
+}
+
+// fail writes a failure: its own status when nothing has been written yet,
+// and in the body when a keepalive already sent the 200.
+func (r *reply) fail(status int, message, model string) {
+ if !r.committed {
+ writeError(r.w, status, message)
+ return
+ }
+ if !r.stream {
+ _ = json.NewEncoder(r.w).Encode(errorBody{Error: errorDetail{Message: message, Code: status}})
+ r.flush()
+ return
+ }
+ r.event(failedChunk(r.id, model, r.created, status, message))
+ _, _ = io.WriteString(r.w, "data: [DONE]\n\n")
+ r.flush()
+}
+
+func (r *reply) event(event chunk) {
+ encoded, err := json.Marshal(event)
+ if err != nil {
+ return
+ }
+ _, _ = io.WriteString(r.w, "data: ")
+ _, _ = r.w.Write(encoded)
+ _, _ = io.WriteString(r.w, "\n\n")
+ r.flush()
+}
+
+func (r *reply) flush() {
+ if flusher, ok := r.w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+// mint is n random bytes as hex.
+func mint(n int) (string, error) {
+ raw := make([]byte, n)
+ if _, err := rand.Read(raw); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(raw), nil
+}
+
+// mustMint is an id that only has to be unlikely to repeat; a machine whose
+// random source failed gets a clock reading instead.
+func mustMint(n int) string {
+ if id, err := mint(n); err == nil {
+ return id
+ }
+ return fmt.Sprintf("%x", time.Now().UnixNano())
+}
diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go
new file mode 100644
index 0000000000..5276bb3ad9
--- /dev/null
+++ b/internal/provider/modelapi/server_test.go
@@ -0,0 +1,866 @@
+package modelapi_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "math"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ lanes "github.com/Agent-Field/codeaf/internal/lane"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// seenCall is one call as the funnel was handed it: the model, the messages,
+// the SDK request the options build, and the per-call facts on the context.
+type seenCall struct {
+ completerModel string
+ messages []ai.Message
+ request ai.Request
+ cacheKey string
+ effort provider.Effort
+ reasoning []provider.MessageReasoning
+ role lanes.Role
+}
+
+// script is a funnel a test writes: it records every call and answers with
+// whatever reply says.
+type script struct {
+ mu sync.Mutex
+ seen []seenCall
+ reply func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error)
+}
+
+func (s *script) completerFor(model string) modelapi.Completer {
+ return scriptCall{script: s, model: model}
+}
+
+func (s *script) calls() []seenCall {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]seenCall(nil), s.seen...)
+}
+
+type scriptCall struct {
+ script *script
+ model string
+}
+
+func (c scriptCall) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ if err := option(&request); err != nil {
+ return nil, err
+ }
+ }
+ c.script.mu.Lock()
+ c.script.seen = append(c.script.seen, seenCall{
+ completerModel: c.model, messages: messages, request: request,
+ cacheKey: provider.CacheKeyFrom(ctx), effort: provider.ReasoningEffortFrom(ctx),
+ reasoning: provider.MessageReasoningFrom(ctx), role: provider.RoleFrom(ctx),
+ })
+ c.script.mu.Unlock()
+ return c.script.reply(ctx, request.Model, messages, request)
+}
+
+// bill is the funnel telling whoever armed the call what an answer cost, the
+// way the provider's decode does.
+func bill(ctx context.Context, model string, in, out, cached int, cost float64) {
+ if sink := provider.BillingSinkFrom(ctx); sink != nil {
+ sink(provider.Billed{Model: model, PromptTokens: in, CompletionTokens: out, CachedTokens: cached, Cost: cost})
+ }
+}
+
+// saying is an answer of words.
+func saying(model, text string) *ai.Response {
+ return &ai.Response{ID: "upstream-1", Model: model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: text}}}, FinishReason: "stop",
+ }}}
+}
+
+// words answers every call with its text, billed at cost.
+func words(text string, cost float64) func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) {
+ return func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ bill(ctx, model, 100, 20, 30, cost)
+ return saying(model, text), nil
+ }
+}
+
+// open starts an API for one test and closes it after.
+func open(t *testing.T, config modelapi.Config) (*modelapi.Server, delegate.ModelAPI) {
+ t.Helper()
+ server, err := modelapi.Open(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ return server, server.API()
+}
+
+// post sends one body to the API with the token given.
+func post(t *testing.T, api delegate.ModelAPI, token, body string) (int, []byte) {
+ t.Helper()
+ request, err := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ payload, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return response.StatusCode, payload
+}
+
+// errorOf reads the router's error envelope.
+func errorOf(t *testing.T, payload []byte) (string, int) {
+ t.Helper()
+ var body struct {
+ Error struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(payload, &body); err != nil {
+ t.Fatalf("not an error envelope: %s", payload)
+ }
+ return body.Error.Message, body.Error.Code
+}
+
+// whole reads a whole completion.
+type whole struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Model string `json:"model"`
+ Choices []struct {
+ Message struct {
+ Role string `json:"role"`
+ Content *string `json:"content"`
+ Reasoning string `json:"reasoning"`
+ ToolCalls []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ Cost *float64 `json:"cost"`
+ PromptTokensDetails struct {
+ CachedTokens int `json:"cached_tokens"`
+ } `json:"prompt_tokens_details"`
+ } `json:"usage"`
+}
+
+// events splits an event stream into its data payloads and counts its
+// comment lines.
+func events(body []byte) (data []string, comments int) {
+ for _, block := range strings.Split(string(body), "\n\n") {
+ block = strings.TrimSpace(block)
+ switch {
+ case block == "":
+ case strings.HasPrefix(block, ":"):
+ comments++
+ case strings.HasPrefix(block, "data: "):
+ data = append(data, strings.TrimPrefix(block, "data: "))
+ }
+ }
+ return data, comments
+}
+
+// rawTurns is every line the conversation log holds, in the order written.
+func rawTurns(t *testing.T, dir string) []delegate.Turn {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(dir, delegate.ConversationFile))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var turns []delegate.Turn
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+ var turn delegate.Turn
+ if err := json.Unmarshal([]byte(line), &turn); err != nil {
+ t.Fatalf("a log line does not parse: %s", line)
+ }
+ turns = append(turns, turn)
+ }
+ return turns
+}
+
+const hello = `{"model":"deepseek/deepseek-v4-flash-0731","messages":[{"role":"user","content":"hi"}]}`
+
+// THE TOKEN IS THE ONLY WAY IN, AND IT DIES WITH THE RUN: no token and a wrong
+// one are both refused in the router's own shape, the right one is answered,
+// and after Close the API hands out no token and the port answers nobody.
+func TestTheAPIOpensToItsTokenAloneAndClosesWithTheRun(t *testing.T) {
+ calls := &script{reply: words("hello", 0.01)}
+ server, api := open(t, modelapi.Config{CompleterFor: calls.completerFor})
+ if !strings.HasPrefix(api.BaseURL, "http://127.0.0.1:") || !strings.HasSuffix(api.BaseURL, "/v1") || len(api.Token) < 32 {
+ t.Fatalf("api = %+v, want a loopback /v1 base and a real token", api)
+ }
+ for _, token := range []string{"", "not-the-token"} {
+ status, payload := post(t, api, token, hello)
+ if message, code := errorOf(t, payload); status != http.StatusUnauthorized || code != 401 || message == "" {
+ t.Fatalf("token %q: status %d body %s, want 401 in the router's shape", token, status, payload)
+ }
+ }
+ if len(calls.calls()) != 0 {
+ t.Fatal("a refused token reached the funnel")
+ }
+ if status, payload := post(t, api, api.Token, hello); status != http.StatusOK {
+ t.Fatalf("the right token was answered %d: %s", status, payload)
+ }
+ if err := server.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if after := server.API(); after.Token != "" || after.Ready() {
+ t.Fatalf("a closed API still hands out %+v", after)
+ }
+ request, _ := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(hello))
+ request.Header.Set("Authorization", "Bearer "+api.Token)
+ if response, err := http.DefaultClient.Do(request); err == nil {
+ response.Body.Close()
+ t.Fatalf("the old token still opens a closed API: %d", response.StatusCode)
+ }
+}
+
+// A CALL PAST THE CEILING IS NEVER MADE: it is answered 402 in the router's
+// shape, the funnel is not asked, the refusal is a turn of the log, and every
+// charge before it reached the bank in order with the run's rising total.
+func TestACallPastTheCeilingIsRefusedBeforeItIsMade(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("ok", 0.06)}
+ var mu sync.Mutex
+ var banked []modelapi.Charge
+ server, api := open(t, modelapi.Config{
+ TaskDir: dir, CompleterFor: calls.completerFor, Ceiling: 0.10,
+ Bank: func(charge modelapi.Charge) {
+ mu.Lock()
+ defer mu.Unlock()
+ banked = append(banked, charge)
+ },
+ })
+ for call := 0; call < 2; call++ {
+ if status, payload := post(t, api, api.Token, hello); status != http.StatusOK {
+ t.Fatalf("call %d under the ceiling was answered %d: %s", call+1, status, payload)
+ }
+ }
+ status, payload := post(t, api, api.Token, hello)
+ message, code := errorOf(t, payload)
+ if status != http.StatusPaymentRequired || code != 402 || !strings.Contains(message, "ceiling of $0.10") {
+ t.Fatalf("the call past the ceiling was answered %d: %s", status, payload)
+ }
+ if got := len(calls.calls()); got != 2 {
+ t.Fatalf("the funnel was asked %d times, want the two calls under the ceiling and not the third", got)
+ }
+ if refused := server.RefusedAtCeiling(); refused != 1 {
+ t.Fatalf("refused at the ceiling = %d, want the one call", refused)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(banked) != 2 || banked[0].Spent != 0.06 || banked[1].Spent != 0.12 || banked[1].CostUSD != 0.06 ||
+ banked[0].TokensIn != 100 || banked[0].Cached != 30 || banked[0].Model != "deepseek/deepseek-v4-flash-0731" {
+ t.Fatalf("banked = %+v", banked)
+ }
+ turns, err := delegate.ReadTurns(dir, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(turns) != 3 || turns[2].Refused == "" || turns[2].Ended.IsZero() || turns[2].CostUSD != 0 || turns[2].InFlight() {
+ t.Fatalf("turns = %+v, want the third written as a refusal that cost nothing", turns)
+ }
+}
+
+// THE WHOLE BODY REACHES THE FUNNEL AND THE WHOLE ANSWER COMES BACK: tools and
+// the program's own tool_choice, a tool call and its result, the reasoning
+// depth, the cache key, the response format, the working handed back — and
+// the model's tool calls, its words and its working on the way out.
+func TestTheCallCrossesIntoTheFunnelWholeAndTheAnswerComesBackWhole(t *testing.T) {
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ provider.EmitReasoning(ctx, "reasoning", "plan first", json.RawMessage(`[{"type":"reasoning.text","text":"plan first"}]`))
+ bill(ctx, model, 1200, 400, 1000, 0.0042)
+ return &ai.Response{ID: "upstream", Model: model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "call_9", Type: "function", Function: ai.ToolCallFunction{Name: "edit", Arguments: `{"path":"a.go"}`}}}},
+ }}}, nil
+ }}
+ _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Role: lanes.RoleLeafAttached})
+ body := `{
+ "model": "moonshotai/kimi-k2.6",
+ "messages": [
+ {"role": "system", "content": "be brief"},
+ {"role": "user", "content": "fix it"},
+ {"role": "assistant", "content": "", "reasoning_content": "earlier working", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}]},
+ {"role": "tool", "tool_call_id": "call_1", "content": "FAIL"}
+ ],
+ "tools": [{"type": "function", "function": {"name": "edit", "parameters": {"type": "object"}}}],
+ "tool_choice": "required",
+ "max_tokens": 4096,
+ "reasoning_effort": "low",
+ "prompt_cache_key": "sd-main",
+ "response_format": {"type": "json_object"}
+ }`
+ status, payload := post(t, api, api.Token, body)
+ if status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ seen := calls.calls()
+ if len(seen) != 1 {
+ t.Fatalf("the funnel was asked %d times", len(seen))
+ }
+ call := seen[0]
+ if call.completerModel != "moonshotai/kimi-k2.6" || call.request.Model != "moonshotai/kimi-k2.6" {
+ t.Fatalf("model = %q / %q", call.completerModel, call.request.Model)
+ }
+ if len(call.messages) != 4 || call.messages[2].ToolCalls[0].ID != "call_1" || call.messages[3].ToolCallID != "call_1" {
+ t.Fatalf("messages = %+v", call.messages)
+ }
+ if len(call.request.Tools) != 1 || call.request.Tools[0].Function.Name != "edit" || call.request.ToolChoice != "required" {
+ t.Fatalf("tools %+v choice %#v", call.request.Tools, call.request.ToolChoice)
+ }
+ if call.request.MaxTokens == nil || *call.request.MaxTokens != 4096 || call.request.ResponseFormat == nil || call.request.ResponseFormat.Type != "json_object" {
+ t.Fatalf("request = %+v", call.request)
+ }
+ if call.cacheKey != "sd-main" || call.effort != provider.EffortLow || call.role != lanes.RoleLeafAttached {
+ t.Fatalf("cache key %q effort %q role %q", call.cacheKey, call.effort, call.role)
+ }
+ if len(call.reasoning) != 4 || call.reasoning[2].Field != "reasoning_content" || call.reasoning[2].Text != "earlier working" {
+ t.Fatalf("working handed back = %+v", call.reasoning)
+ }
+ var answer whole
+ if err := json.Unmarshal(payload, &answer); err != nil {
+ t.Fatalf("%v: %s", err, payload)
+ }
+ choice := answer.Choices[0]
+ if answer.Object != "chat.completion" || choice.FinishReason != "tool_calls" || choice.Message.Content != nil ||
+ len(choice.Message.ToolCalls) != 1 || choice.Message.ToolCalls[0].ID != "call_9" || choice.Message.ToolCalls[0].Function.Arguments != `{"path":"a.go"}` {
+ t.Fatalf("answer = %s", payload)
+ }
+ if choice.Message.Reasoning != "plan first" || !strings.Contains(string(payload), `"reasoning_details":[{"type":"reasoning.text"`) {
+ t.Fatalf("the model's working did not come back: %s", payload)
+ }
+ if answer.Usage.Cost == nil || *answer.Usage.Cost != 0.0042 || answer.Usage.PromptTokens != 1200 || answer.Usage.TotalTokens != 1600 || answer.Usage.PromptTokensDetails.CachedTokens != 1000 {
+ t.Fatalf("usage = %+v", answer.Usage)
+ }
+}
+
+// A THINKING MODEL'S WORKING MAKES THE ROUND TRIP: an endpoint that writes it
+// as reasoning_content has it handed to the program under the router's own
+// `reasoning`, and the program handing it back that way has it replayed to the
+// endpoint under the field it came in with.
+func TestAModelsWorkingGoesOutUnderTheRoutersNameAndComesBackUnderItsOwn(t *testing.T) {
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ provider.EmitReasoning(ctx, "reasoning_content", "run the tests first", nil)
+ return &ai.Response{Model: model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "c1", Type: "function", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}},
+ }}}, nil
+ }}
+ _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor})
+ status, payload := post(t, api, api.Token, `{"model":"m","messages":[{"role":"user","content":"fix it"}]}`)
+ if status != http.StatusOK || !strings.Contains(string(payload), `"reasoning":"run the tests first"`) || strings.Contains(string(payload), "reasoning_content") {
+ t.Fatalf("status %d, the working did not go out under the router's name: %s", status, payload)
+ }
+ back := `{"model":"m","messages":[{"role":"user","content":"fix it"},` +
+ `{"role":"assistant","content":null,"reasoning":"run the tests first","tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{}"}}]},` +
+ `{"role":"tool","tool_call_id":"c1","content":"ok"}]}`
+ if status, payload := post(t, api, api.Token, back); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ seen := calls.calls()
+ if len(seen) != 2 || len(seen[1].reasoning) != 3 || seen[1].reasoning[1].Field != "reasoning_content" || seen[1].reasoning[1].Text != "run the tests first" {
+ t.Fatalf("the working handed back reached the funnel as %+v", seen[len(seen)-1].reasoning)
+ }
+}
+
+// A STREAM IS THE ROUTER'S STREAM: the words as a delta, the finish, then a
+// chunk carrying the usage with its cost, then [DONE].
+func TestAStreamedAnswerEndsWithItsCostThenDone(t *testing.T) {
+ calls := &script{reply: words("all green", 0.0125)}
+ _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor})
+ status, payload := post(t, api, api.Token, `{"model":"z-ai/glm-5.1","stream":true,"messages":[{"role":"user","content":"go"}]}`)
+ if status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ data, _ := events(payload)
+ if len(data) < 3 || data[len(data)-1] != "[DONE]" {
+ t.Fatalf("events = %q, want chunks and then [DONE]", data)
+ }
+ var content strings.Builder
+ var finish string
+ var cost *float64
+ for _, event := range data[:len(data)-1] {
+ var chunk struct {
+ Object string `json:"object"`
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ } `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage *struct {
+ Cost *float64 `json:"cost"`
+ } `json:"usage"`
+ }
+ if err := json.Unmarshal([]byte(event), &chunk); err != nil || chunk.Object != "chat.completion.chunk" {
+ t.Fatalf("event %s: %v", event, err)
+ }
+ for _, choice := range chunk.Choices {
+ content.WriteString(choice.Delta.Content)
+ if choice.FinishReason != nil {
+ finish = *choice.FinishReason
+ }
+ }
+ if chunk.Usage != nil {
+ cost = chunk.Usage.Cost
+ }
+ }
+ if content.String() != "all green" || finish != "stop" || cost == nil || *cost != 0.0125 {
+ t.Fatalf("content %q finish %q cost %v", content.String(), finish, cost)
+ }
+}
+
+// A MODEL THAT THINKS FOR A LONG TIME NEVER LOOKS LIKE A DEAD CONNECTION: a
+// stream is sent comment lines while it waits, and a whole body is sent the
+// whitespace JSON allows before its value, and both still read as what they
+// are.
+func TestAWaitingAnswerSaysItIsStillComing(t *testing.T) {
+ calls := &script{reply: func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) {
+ time.Sleep(150 * time.Millisecond)
+ return words("slow", 0.001)(ctx, model, messages, request)
+ }}
+ _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Keepalive: 20 * time.Millisecond})
+ _, payload := post(t, api, api.Token, `{"model":"m","stream":true,"messages":[{"role":"user","content":"go"}]}`)
+ data, comments := events(payload)
+ if comments < 2 || !strings.HasPrefix(string(payload), ": keepalive\n\n") || data[len(data)-1] != "[DONE]" {
+ t.Fatalf("%d comments before the answer, want several:\n%s", comments, payload)
+ }
+ status, payload := post(t, api, api.Token, `{"model":"m","messages":[{"role":"user","content":"go"}]}`)
+ if status != http.StatusOK || !strings.HasPrefix(string(payload), "\n") {
+ t.Fatalf("status %d, a whole body with no whitespace kept alive: %q", status, payload)
+ }
+ var answer whole
+ if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil || *answer.Choices[0].Message.Content != "slow" {
+ t.Fatalf("the kept-alive body no longer reads: %v %s", err, payload)
+ }
+ if err := json.Unmarshal(payload, &answer); err != nil {
+ t.Fatalf("a JSON reader refuses the leading whitespace: %v", err)
+ }
+}
+
+// EVERY CALL IS WRITTEN TWICE UNDER ONE NUMBER — when it starts and when it
+// ends — and what a turn says it sent is only what the thread's previous
+// request did not carry.
+func TestEveryCallIsOneTurnWrittenAtItsStartAndItsEnd(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("done", 0.002)}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor})
+ first := `{"model":"qwen/qwen3.6-plus","messages":[{"role":"system","content":"rules"},{"role":"user","content":"fix the test"}]}`
+ second := `{"model":"qwen/qwen3.6-plus","messages":[{"role":"system","content":"rules"},{"role":"user","content":"fix the test"},` +
+ `{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"cmd\":\"go test\"}"}}]},` +
+ `{"role":"tool","tool_call_id":"c1","content":"FAIL: TestX"}]}`
+ for _, body := range []string{first, second} {
+ if status, payload := post(t, api, api.Token, body); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ }
+ lines := rawTurns(t, dir)
+ if len(lines) != 4 {
+ t.Fatalf("%d records, want two per call", len(lines))
+ }
+ for index, line := range lines {
+ wantSeq, ended := index/2+1, index%2 == 1
+ if line.Seq != wantSeq || line.Ended.IsZero() == ended || line.Thread != delegate.MainThread {
+ t.Fatalf("record %d = %+v, want seq %d ended %v on the main thread", index, line, wantSeq, ended)
+ }
+ }
+ if !lines[0].InFlight() || lines[1].InFlight() {
+ t.Fatal("the start record does not read as in flight, or the end record still does")
+ }
+ turns, err := delegate.ReadTurns(dir, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(turns) != 2 {
+ t.Fatalf("%d turns, want two", len(turns))
+ }
+ one, two := turns[0], turns[1]
+ if len(one.Sent) != 2 || one.Sent[0].Role != "system" || one.Sent[1].Text != "fix the test" || one.Restarted {
+ t.Fatalf("first turn sent %+v", one.Sent)
+ }
+ if len(two.Sent) != 1 || two.Sent[0].Role != "tool" || two.Sent[0].Tool != "bash" || two.Sent[0].Text != "FAIL: TestX" || two.Restarted {
+ t.Fatalf("second turn sent %+v, want only the tool's result", two.Sent)
+ }
+ if two.Model != "qwen/qwen3.6-plus" || two.Served != "" || two.Reply != "done" || two.TokensIn != 100 || two.TokensOut != 20 || two.Cached != 30 || two.CostUSD != 0.002 {
+ t.Fatalf("second turn = %+v", two)
+ }
+}
+
+// A REWRITTEN HISTORY IS SAID TO BE ONE, AND TWO THREADS ARE TWO
+// CONVERSATIONS: each thread's first call sends its whole brief, and a thread
+// whose next request is not its last one extended is a restart.
+func TestARewrittenHistoryIsARestartAndThreadsAreKeptApart(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("ok", 0)}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor})
+ send := func(key, messages string) {
+ t.Helper()
+ body := `{"model":"m","prompt_cache_key":"` + key + `","messages":[` + messages + `]}`
+ if status, payload := post(t, api, api.Token, body); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ }
+ send("coder", `{"role":"user","content":"write it"},{"role":"assistant","content":"written"},{"role":"user","content":"now test it"}`)
+ send("summariser", `{"role":"user","content":"summarise the coder"}`)
+ send("coder", `{"role":"user","content":"summary: written and tested"},{"role":"user","content":"ship it"}`)
+ turns, err := delegate.ReadTurns(dir, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(turns) != 3 {
+ t.Fatalf("%d turns", len(turns))
+ }
+ if turns[0].Thread != "coder" || len(turns[0].Sent) != 2 || turns[0].Restarted {
+ t.Fatalf("coder's first turn = %+v, want its two words of its own and not the model's", turns[0])
+ }
+ if turns[1].Thread != "summariser" || len(turns[1].Sent) != 1 || turns[1].Restarted {
+ t.Fatalf("the second thread's first turn = %+v, want a first call of its own", turns[1])
+ }
+ if !turns[2].Restarted || len(turns[2].Sent) != 2 || turns[2].Sent[0].Text != "summary: written and tested" {
+ t.Fatalf("the rewritten coder turn = %+v, want a restart that sends it whole", turns[2])
+ }
+}
+
+// A LINEAGE NAMED ONLY IN THE ROUTER'S HEADER IS STILL THE CALL'S LINEAGE: it
+// is the cache key the funnel is handed and the thread the log keeps, and a
+// body's own key wins over it.
+func TestTheSessionAffinityHeaderIsTheLineageWhenTheBodyNamesNone(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("ok", 0)}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor})
+ for _, body := range []string{hello, `{"model":"m","prompt_cache_key":"from-body","messages":[{"role":"user","content":"hi"}]}`} {
+ request, _ := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(body))
+ request.Header.Set("Authorization", "Bearer "+api.Token)
+ request.Header.Set("x-session-affinity", "from-header")
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ }
+ seen := calls.calls()
+ if len(seen) != 2 || seen[0].cacheKey != "from-header" || seen[1].cacheKey != "from-body" {
+ t.Fatalf("cache keys = %+v", seen)
+ }
+ if turns, _ := delegate.ReadTurns(dir, 0); len(turns) != 2 || turns[0].Thread != "from-header" || turns[1].Thread != "from-body" {
+ t.Fatalf("threads = %+v", turns)
+ }
+}
+
+// A MODEL THIS MACHINE CANNOT SERVE IS ANSWERED ON THE RUN'S SEAT, AND THE
+// TURN SAYS SO: the program asked for one id, the funnel was handed the seat,
+// and Served names what answered.
+func TestAModelThisMachineCannotServeIsAnsweredOnTheSeat(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("from the seat", 0.003)}
+ _, api := open(t, modelapi.Config{
+ TaskDir: dir, CompleterFor: calls.completerFor, Seat: "mybox/qwen3-coder",
+ Serves: func(model string) bool { return strings.HasPrefix(model, "mybox/") },
+ })
+ status, payload := post(t, api, api.Token, `{"model":"openrouter/deepseek/deepseek-v4-pro","messages":[{"role":"user","content":"go"}]}`)
+ if status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ if seen := calls.calls(); len(seen) != 1 || seen[0].request.Model != "mybox/qwen3-coder" || seen[0].completerModel != "mybox/qwen3-coder" {
+ t.Fatalf("the funnel was handed %+v, want the seat", seen)
+ }
+ turns, _ := delegate.ReadTurns(dir, 0)
+ if len(turns) != 1 || turns[0].Model != "openrouter/deepseek/deepseek-v4-pro" || turns[0].Served != "mybox/qwen3-coder" {
+ t.Fatalf("turn = %+v, want the ask kept and the seat named as what answered", turns)
+ }
+}
+
+// A CALL IS NEVER LOST ONLY BECAUSE THIS MACHINE DOES NOT KNOW THE ID: when the
+// funnel itself says it cannot serve the ask — no key for its service — the
+// call goes out once more on the seat.
+func TestAnAskTheFunnelCannotServeFallsToTheSeat(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) {
+ if model != "seat/model" {
+ return nil, provider.ErrNoAPIKey
+ }
+ return words("seated", 0.001)(ctx, model, messages, request)
+ }}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor, Seat: "seat/model"})
+ status, payload := post(t, api, api.Token, `{"model":"minimax/minimax-m2.7","messages":[{"role":"user","content":"go"}]}`)
+ if status != http.StatusOK || !strings.Contains(string(payload), "seated") {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ if seen := calls.calls(); len(seen) != 2 || seen[1].request.Model != "seat/model" {
+ t.Fatalf("the funnel saw %+v, want the ask and then the seat", seen)
+ }
+ if turns, _ := delegate.ReadTurns(dir, 0); len(turns) != 1 || turns[0].Served != "seat/model" || turns[0].Failed != "" {
+ t.Fatalf("turns = %+v", turns)
+ }
+}
+
+// A MODEL'S FAILURE IS THE ROUTER'S ERROR WITH A STATUS THAT MEANS THE SAME
+// THING — and an account refused upstream is a gateway's refusal, never the
+// program's own token being wrong.
+func TestAModelFailureIsTheRoutersErrorAndItsTurnSaysSo(t *testing.T) {
+ dir := t.TempDir()
+ var refusal error
+ calls := &script{reply: func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) { return nil, refusal }}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor})
+ for _, row := range []struct {
+ err error
+ status int
+ }{
+ {&provider.APIError{Status: 429, Message: "slow down"}, 429},
+ {&provider.APIError{Status: 401, Message: "no such account"}, 502},
+ {errors.New("connection reset"), 502},
+ } {
+ refusal = row.err
+ status, payload := post(t, api, api.Token, hello)
+ message, code := errorOf(t, payload)
+ if status != row.status || code != row.status || message == "" {
+ t.Fatalf("%v: status %d body %s, want %d", row.err, status, payload, row.status)
+ }
+ }
+ turns, _ := delegate.ReadTurns(dir, 0)
+ if len(turns) != 3 || !strings.Contains(turns[0].Failed, "slow down") || turns[0].Ended.IsZero() {
+ t.Fatalf("turns = %+v, want each failure written with its sentence", turns)
+ }
+ // A run started with no road answers with that sentence and makes no call.
+ _, bare := open(t, modelapi.Config{TaskDir: t.TempDir()})
+ status, payload := post(t, bare, bare.Token, hello)
+ if message, _ := errorOf(t, payload); status != http.StatusServiceUnavailable || !strings.Contains(message, "no road to a model") {
+ t.Fatalf("a road-less run answered %d: %s", status, payload)
+ }
+}
+
+// A STREAM CUT BEFORE ITS USAGE BLOCK IS PRICED LATE AND STILL COUNTED ONCE:
+// the receipt reaches the bank marked late, and the call's turn is written
+// again with the figure; a receipt that never comes is told as unbilled.
+func TestALateReceiptIsBankedAndItsTurnRewritten(t *testing.T) {
+ dir := t.TempDir()
+ late := make(chan struct{})
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ // Owed the way the provider owes a receipt: before the fetch, and
+ // answered after the sink.
+ done := provider.ReceiptPendingFrom(ctx)()
+ sink := provider.ReconcileSinkFrom(ctx)
+ go func() {
+ defer done()
+ time.Sleep(50 * time.Millisecond)
+ sink(provider.Reconciled{Billed: provider.Billed{Model: model, PromptTokens: 50, CompletionTokens: 5, Cost: 0.02}, Found: true})
+ sink(provider.Reconciled{Billed: provider.Billed{Model: "other"}, Found: false})
+ close(late)
+ }()
+ return saying(model, "cut short"), nil
+ }}
+ var mu sync.Mutex
+ var banked []modelapi.Charge
+ var unbilled []string
+ _, api := open(t, modelapi.Config{
+ TaskDir: dir, CompleterFor: calls.completerFor,
+ Bank: func(charge modelapi.Charge) { mu.Lock(); banked = append(banked, charge); mu.Unlock() },
+ Unbilled: func(model string) { mu.Lock(); unbilled = append(unbilled, model); mu.Unlock() },
+ })
+ if status, payload := post(t, api, api.Token, hello); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ <-late
+ mu.Lock()
+ if len(banked) != 1 || !banked[0].Late || banked[0].CostUSD != 0.02 || banked[0].Spent != 0.02 || len(unbilled) != 1 || unbilled[0] != "other" {
+ t.Fatalf("banked %+v unbilled %v", banked, unbilled)
+ }
+ mu.Unlock()
+ turns, _ := delegate.ReadTurns(dir, 0)
+ if len(turns) != 1 || turns[0].CostUSD != 0.02 || turns[0].TokensIn != 50 {
+ t.Fatalf("turn = %+v, want it rewritten with the late receipt", turns)
+ }
+}
+
+// THE RUN'S BOOKS CLOSE WITH THE RECEIPT OF THE CALL IT WAS CUT IN: the funnel
+// owes a receipt for a call that ended without its usage block, and it arrives
+// well after the call has returned — the stopped runs of 2026-09-23 saw it
+// twenty seconds later. Close waits for it, so the total read after Close and
+// the bank both hold it, and the watcher is told once how many were owed. A
+// receipt that never comes costs the ending no more than the bound.
+func TestCloseWaitsForTheReceiptOwedOnACutCall(t *testing.T) {
+ gate := make(chan struct{})
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ // The provider's own order: owed before the fetch, answered after the
+ // sink has the money.
+ done := provider.ReceiptPendingFrom(ctx)()
+ sink := provider.ReconcileSinkFrom(ctx)
+ go func() {
+ defer done()
+ <-gate
+ sink(provider.Reconciled{Billed: provider.Billed{Model: model, PromptTokens: 52139, CompletionTokens: 4895, Cost: 0.058188488}, Found: true})
+ }()
+ return saying(model, "cut short"), nil
+ }}
+ var mu sync.Mutex
+ var banked []modelapi.Charge
+ var settling []int
+ server, api := open(t, modelapi.Config{
+ CompleterFor: calls.completerFor,
+ Bank: func(charge modelapi.Charge) { mu.Lock(); banked = append(banked, charge); mu.Unlock() },
+ Settling: func(owed int) { mu.Lock(); settling = append(settling, owed); mu.Unlock() },
+ })
+ if status, payload := post(t, api, api.Token, hello); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ if spent := server.Spent(); spent != 0 {
+ t.Fatalf("spent %v before the receipt came", spent)
+ }
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ close(gate)
+ }()
+ if err := server.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if spent := server.Spent(); spent != 0.058188488 {
+ t.Fatalf("spent after Close = %v, want the late receipt's $0.058188488 in it", spent)
+ }
+ mu.Lock()
+ if len(banked) != 1 || !banked[0].Late || banked[0].CostUSD != 0.058188488 || len(settling) != 1 || settling[0] != 1 {
+ mu.Unlock()
+ t.Fatalf("banked %+v settling %v, want the one late charge banked before Close returned, told once", banked, settling)
+ }
+ mu.Unlock()
+
+ // A receipt that never comes: Close gives up at the bound.
+ defer modelapi.ShortenReceiptWait(150 * time.Millisecond)()
+ never := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ provider.ReceiptPendingFrom(ctx)()
+ return saying(model, "cut short"), nil
+ }}
+ stuck, stuckAPI := open(t, modelapi.Config{CompleterFor: never.completerFor})
+ if status, payload := post(t, stuckAPI, stuckAPI.Token, hello); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ began := time.Now()
+ if err := stuck.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if waited := time.Since(began); waited < 100*time.Millisecond || waited > 5*time.Second {
+ t.Fatalf("Close waited %s for a receipt that never came, want about the bound", waited)
+ }
+}
+
+// THE PAGE NAMES THE MODEL THAT ANSWERED. When nothing here could take the ask
+// or the seat, the conversation's own pool put the call on its seat, which this
+// API never hears of — run 3d6d asked qwen and was answered by gpt-5.6-sol, at a
+// price its service does not report. The funnel's bill names who answered, and
+// the turn says so; an ask answered by the same model under another spelling,
+// or by a dated build of it, names nothing more. A SIBLING IS ANOTHER MODEL:
+// `gpt-5.5-mini` answering an ask for `gpt-5.5` is named, because a hyphen
+// followed by a word is a different model and only a date or build number is
+// the same one.
+func TestATurnNamesTheModelTheFunnelBilledWhenItIsNotTheAsk(t *testing.T) {
+ for _, row := range []struct {
+ name, asked, billed, answer, want string
+ }{
+ {name: "the pool's seat answered", asked: "qwen/qwen3.6-plus", billed: "gpt-5.6-sol", want: "gpt-5.6-sol"},
+ {name: "the same model without its service", asked: "openrouter/deepseek/deepseek-v4-pro", billed: "deepseek/deepseek-v4-pro", want: ""},
+ {name: "a dated build of the ask", asked: "deepseek/deepseek-v4-pro", billed: "deepseek/deepseek-v4-pro-0731", want: ""},
+ {name: "a full date on the build", asked: "openai/gpt-5.5", billed: "openai/gpt-5.5-20260731", want: ""},
+ {name: "a variant of the ask", asked: "qwen/qwen3.6-plus", billed: "qwen/qwen3.6-plus:free", want: ""},
+ {name: "a smaller sibling answered", asked: "openai/gpt-5.5", billed: "openai/gpt-5.5-mini", want: "openai/gpt-5.5-mini"},
+ {name: "a faster sibling answered", asked: "deepseek/deepseek-v4", billed: "deepseek/deepseek-v4-flash", want: "deepseek/deepseek-v4-flash"},
+ {name: "a thinking sibling answered", asked: "moonshotai/kimi-k2", billed: "moonshotai/kimi-k2-thinking", want: "moonshotai/kimi-k2-thinking"},
+ {name: "the ask is the sibling", asked: "deepseek/deepseek-v4-flash", billed: "deepseek/deepseek-v4", want: "deepseek/deepseek-v4"},
+ {name: "nothing billed, the answer names another", asked: "moonshotai/kimi-k2.6", answer: "z-ai/glm-5.1", want: "z-ai/glm-5.1"},
+ } {
+ t.Run(row.name, func(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ answered := model
+ if row.billed != "" {
+ // A service that reports no price: tokens, and no dollars.
+ bill(ctx, row.billed, 58511, 405, 55356, 0)
+ answered = row.billed
+ }
+ if row.answer != "" {
+ answered = row.answer
+ }
+ return saying(answered, "done"), nil
+ }}
+ _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor})
+ body := `{"model":"` + row.asked + `","messages":[{"role":"user","content":"go"}]}`
+ if status, payload := post(t, api, api.Token, body); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ turns, _ := delegate.ReadTurns(dir, 0)
+ if len(turns) != 1 || turns[0].Model != row.asked || turns[0].Served != row.want {
+ t.Fatalf("turn = %+v, want the ask %q kept and %q named as what answered", turns, row.asked, row.want)
+ }
+ })
+ }
+}
+
+// A RUN WITH NOTHING LEFT OF ITS LIMIT MAKES NO CALL AT ALL. The conversation
+// hands such a run the smallest positive ceiling, because zero means none; the
+// first call used to go through and be paid for, since nothing spent was still
+// "under" it. It is refused with the ceiling's own sentence, and the funnel is
+// never asked.
+func TestARunWhoseCeilingIsAlreadySpentMakesNoCall(t *testing.T) {
+ dir := t.TempDir()
+ calls := &script{reply: words("paid for", 0.139463)}
+ server, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor, Ceiling: math.SmallestNonzeroFloat64})
+ status, payload := post(t, api, api.Token, hello)
+ message, code := errorOf(t, payload)
+ if status != http.StatusPaymentRequired || code != 402 ||
+ message != "the run's dollar ceiling of $0.00 is reached ($0.00 spent), so codeaf made no call" {
+ t.Fatalf("the first call of a spent run was answered %d: %s", status, payload)
+ }
+ if len(calls.calls()) != 0 || server.Spent() != 0 || server.RefusedAtCeiling() != 1 {
+ t.Fatalf("the funnel was asked %d times, spent %v, refused %d", len(calls.calls()), server.Spent(), server.RefusedAtCeiling())
+ }
+}
+
+// AN ANSWER NOTHING PRICED IS NOT LEFT SILENT. Every call a run makes rides a
+// context that asks the funnel to settle an answer that arrived whole with no
+// usage block the way it settles a cut one: by its receipt, or as a call nobody
+// could price (provider.WithUnmeteredReceipts).
+func TestARunsCallsAskTheFunnelToSettleAnAnswerWithNoUsage(t *testing.T) {
+ armed := make(chan bool, 1)
+ calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) {
+ armed <- provider.UnmeteredReceiptsFrom(ctx)
+ return saying(model, "whole"), nil
+ }}
+ _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor})
+ if status, payload := post(t, api, api.Token, hello); status != http.StatusOK {
+ t.Fatalf("status %d: %s", status, payload)
+ }
+ if !<-armed {
+ t.Fatal("the call's context does not ask the funnel to settle an answer with no usage block")
+ }
+}
diff --git a/internal/provider/modelapi/threads.go b/internal/provider/modelapi/threads.go
new file mode 100644
index 0000000000..58cf5ea218
--- /dev/null
+++ b/internal/provider/modelapi/threads.go
@@ -0,0 +1,168 @@
+package modelapi
+
+// What the program said that it had not said before.
+//
+// A program talks to its model the way every chat client does: each request
+// carries the whole conversation so far. Written down whole, one call's record
+// would repeat every call before it, and the task page would draw the same
+// brief forty times. So each thread's previous request is remembered — as one
+// fingerprint per message, never the text — and a turn records only what came
+// after it: the brief the first time, then the tools' results and the
+// program's own words.
+//
+// A PROGRAM THAT REWRITES ITS HISTORY IS SAID TO HAVE DONE SO. When a request
+// is not the previous one with more added — a compaction, a summary of old
+// turns in place of the turns — nothing is a delta of anything, so the turn is
+// marked Restarted and records what the program sent, whole (capped where the
+// log is written, delegate.AppendTurn).
+//
+// THE MODEL'S OWN REPLIES ARE NOT SENT WORDS. An assistant message on a request
+// is the program handing the model's last answer back to it; the page already
+// drew that answer on the turn that produced it, so it is skipped here.
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// threads is every thread's previous request, as fingerprints, and the field
+// its model's working last arrived on. It is guarded by the server's lock.
+type threads struct {
+ previous map[string][]string
+ fields map[string]string
+}
+
+// arrived remembers the field a thread's working came in on, so working the
+// program hands back under the router's name is replayed under the field the
+// endpoint wrote it with.
+func (t *threads) arrived(thread, field string) {
+ if field == "" {
+ return
+ }
+ if t.fields == nil {
+ t.fields = map[string]string{}
+ }
+ t.fields[thread] = field
+}
+
+// name gives every piece of handed-back working with no field of its own the
+// field its thread's working last arrived on — the router's `reasoning` when
+// the thread has not said — so no working reaches the encoder unnamed.
+func (t *threads) name(thread string, working []provider.MessageReasoning) []provider.MessageReasoning {
+ field := t.fields[thread]
+ if field == "" {
+ field = "reasoning"
+ }
+ for index := range working {
+ if working[index].Field == "" && working[index].Text != "" {
+ working[index].Field = field
+ }
+ }
+ return working
+}
+
+// delta answers what this request adds to the thread's previous one, and
+// remembers this request as the thread's previous from now on — whatever the
+// call comes to, because "previous" is the previous request, not the previous
+// answer.
+func (t *threads) delta(thread string, messages []ai.Message) (sent []delegate.Said, restarted bool) {
+ if t.previous == nil {
+ t.previous = map[string][]string{}
+ }
+ prints := make([]string, len(messages))
+ for index, message := range messages {
+ prints[index] = fingerprint(message)
+ }
+ before := t.previous[thread]
+ start := len(before)
+ if !extends(prints, before) {
+ restarted, start = true, 0
+ }
+ t.previous[thread] = prints
+ names := toolNames(messages)
+ for _, message := range messages[start:] {
+ if message.Role == "assistant" {
+ continue
+ }
+ sent = append(sent, said(message, names))
+ }
+ return sent, restarted
+}
+
+// extends reports whether a request is the previous one with messages added.
+func extends(now, before []string) bool {
+ if len(before) > len(now) {
+ return false
+ }
+ for index, print := range before {
+ if now[index] != print {
+ return false
+ }
+ }
+ return true
+}
+
+// fingerprint is one message's identity: its role, every part of its content,
+// its tool calls and the call it answers. It is a hash so a thread's memory is
+// a few dozen bytes a message whatever the message weighed.
+func fingerprint(message ai.Message) string {
+ encoded, _ := json.Marshal(struct {
+ Role string `json:"r"`
+ Content []ai.ContentPart `json:"c"`
+ ToolCalls []ai.ToolCall `json:"t"`
+ ToolCallID string `json:"i"`
+ }{message.Role, message.Content, message.ToolCalls, message.ToolCallID})
+ sum := sha256.Sum256(encoded)
+ return hex.EncodeToString(sum[:12])
+}
+
+// toolNames maps every tool call on the request to the tool it named, so a
+// tool's result can say which tool it answers.
+func toolNames(messages []ai.Message) map[string]string {
+ names := map[string]string{}
+ for _, message := range messages {
+ for _, call := range message.ToolCalls {
+ if call.ID != "" {
+ names[call.ID] = call.Function.Name
+ }
+ }
+ }
+ return names
+}
+
+// said is one message as the log writes it: whose, which tool it answers, and
+// its words, with a word in brackets standing for anything that is not text.
+func said(message ai.Message, names map[string]string) delegate.Said {
+ var words []string
+ for _, part := range message.Content {
+ switch part.Type {
+ case "text":
+ if part.Text != "" {
+ words = append(words, part.Text)
+ }
+ case "image_url":
+ words = append(words, "[image]")
+ case "video_url":
+ words = append(words, "[video]")
+ case "input_audio":
+ words = append(words, "[audio]")
+ case "file":
+ words = append(words, "[file]")
+ default:
+ if part.Type != "" {
+ words = append(words, "["+part.Type+"]")
+ }
+ }
+ }
+ entry := delegate.Said{Role: message.Role, Text: strings.Join(words, "\n")}
+ if message.Role == "tool" {
+ entry.Tool = names[message.ToolCallID]
+ }
+ return entry
+}
diff --git a/internal/provider/modelapi/wire.go b/internal/provider/modelapi/wire.go
new file mode 100644
index 0000000000..631eee4ee8
--- /dev/null
+++ b/internal/provider/modelapi/wire.go
@@ -0,0 +1,525 @@
+package modelapi
+
+// The wire: an OpenAI chat-completions body in, codeaf's funnel types out, and
+// the answer back in OpenRouter's own shape.
+//
+// OPENROUTER'S SHAPE, BECAUSE THAT IS WHAT THE PROGRAMS WERE WRITTEN AGAINST.
+// senior-dev reads `usage.cost` off the last chunk of a stream and stops
+// budgeting silently when it is not there, so the answer is not "an
+// OpenAI-compatible reply" in the loose sense: it is the router's own body —
+// the `cost`, the cached-token nesting, the usage chunk after the finish, the
+// `: …` comment lines while a call is thinking — so a program moved from a
+// router onto codeaf cannot tell the road changed.
+//
+// WHAT CODEAF DECIDES IS DROPPED, NOT PASSED. A program's `provider` routing
+// object, its `models` fallback list, `route`, `transforms` and `plugins` are
+// how a caller steers OpenRouter; here codeaf's own router steers, with the
+// lane beliefs, pins and ceilings a person set, so those fields are read past.
+// `stream_options` and `usage` are read past too, because usage and its cost
+// are always sent.
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/effort"
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// chatRequest is the body a program sends, as far as codeaf reads it.
+type chatRequest struct {
+ Model string `json:"model"`
+ Messages []json.RawMessage `json:"messages"`
+ Tools []wireTool `json:"tools"`
+ // ToolChoice is "auto", "none", "required" or an object naming one
+ // function; it is kept raw and handed on as the program wrote it.
+ ToolChoice json.RawMessage `json:"tool_choice"`
+ MaxTokens *int `json:"max_tokens"`
+ MaxCompletionTokens *int `json:"max_completion_tokens"`
+ Temperature *float64 `json:"temperature"`
+ Reasoning *wireReasoning `json:"reasoning"`
+ ReasoningEffort string `json:"reasoning_effort"`
+ PromptCacheKey string `json:"prompt_cache_key"`
+ ResponseFormat json.RawMessage `json:"response_format"`
+ Stream bool `json:"stream"`
+}
+
+// wireTool is one tool the program offers its model.
+type wireTool struct {
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
+ } `json:"function"`
+}
+
+// wireReasoning is OpenRouter's unified reasoning object.
+type wireReasoning struct {
+ Effort string `json:"effort"`
+ Enabled *bool `json:"enabled"`
+}
+
+// messageExtras are the fields of one message the SDK's type has no home for:
+// the model's working a program hands back on an assistant message so a
+// thinking model can continue its own tool loop (provider.MessageReasoning).
+type messageExtras struct {
+ Reasoning string `json:"reasoning"`
+ ReasoningContent string `json:"reasoning_content"`
+ ReasoningText string `json:"reasoning_text"`
+ ReasoningDetails json.RawMessage `json:"reasoning_details"`
+}
+
+// call is one decoded request: what the funnel is handed and what the log is
+// written from.
+type call struct {
+ asked string
+ thread string
+ cacheKey string
+ stream bool
+ messages []ai.Message
+ reasoning []provider.MessageReasoning
+ options []ai.Option
+ depth depth
+}
+
+// depth is how hard the program asked its model to think, in codeaf's own
+// words: a rung of the ladder (internal/effort) from low to max, or — for the
+// two requests that are not rungs, the pass switched off and the lowest word
+// the router has — the adapter's own word. At most one of the two is set.
+type depth struct {
+ rung effort.Rung
+ word provider.Effort
+}
+
+// maxRequestBytes bounds one request body. A transcript with pictures in it is
+// megabytes, never this; the bound is the provider's own answer ceiling, so a
+// question can be as large as an answer may be and no larger.
+const maxRequestBytes = 64 << 20
+
+// decodeRequest reads one body into a call, or says in one sentence what is
+// wrong with it — which is the whole of a 400's message.
+func decodeRequest(body []byte) (*call, error) {
+ var request chatRequest
+ if err := json.Unmarshal(body, &request); err != nil {
+ return nil, fmt.Errorf("the body is not a chat-completions request: %v", err)
+ }
+ if len(request.Messages) == 0 {
+ return nil, errors.New("the request carries no messages")
+ }
+ decoded := &call{
+ asked: strings.TrimSpace(request.Model),
+ cacheKey: strings.TrimSpace(request.PromptCacheKey),
+ stream: request.Stream,
+ }
+ decoded.thread = decoded.cacheKey
+ hasReasoning := false
+ for index, raw := range request.Messages {
+ var message ai.Message
+ if err := json.Unmarshal(raw, &message); err != nil {
+ return nil, fmt.Errorf("message %d does not parse: %v", index, err)
+ }
+ message.Role = strings.ToLower(strings.TrimSpace(message.Role))
+ switch message.Role {
+ case "system", "user", "assistant", "tool":
+ case "developer":
+ // OpenAI's newer name for the system turn. Not every model a
+ // person's service carries knows it, and every one knows system.
+ message.Role = "system"
+ default:
+ return nil, fmt.Errorf("message %d has the role %q; a message is system, user, assistant or tool", index, message.Role)
+ }
+ decoded.messages = append(decoded.messages, message)
+ working := provider.MessageReasoning{}
+ if message.Role == "assistant" {
+ var extras messageExtras
+ _ = json.Unmarshal(raw, &extras)
+ working = extras.working()
+ if working.Text != "" || len(working.Details) > 0 {
+ hasReasoning = true
+ }
+ }
+ decoded.reasoning = append(decoded.reasoning, working)
+ }
+ if !hasReasoning {
+ decoded.reasoning = nil
+ }
+ options, err := request.options()
+ if err != nil {
+ return nil, err
+ }
+ decoded.options = options
+ decoded.depth = request.depth()
+ return decoded, nil
+}
+
+// working is the reasoning a program handed back. A field the program named
+// outright is the field it travels under; the router's own `reasoning` — the
+// name every answer here hands the working out under — is left unnamed, and
+// the call names it from what its thread's working last arrived on
+// ([threads.name]), because the provider's replay law is that working goes
+// back under the field it came in with (provider.ReasoningReplayPolicy).
+func (e messageExtras) working() provider.MessageReasoning {
+ working := provider.MessageReasoning{}
+ switch {
+ case e.ReasoningContent != "":
+ working.Field, working.Text = "reasoning_content", e.ReasoningContent
+ case e.ReasoningText != "":
+ working.Field, working.Text = "reasoning_text", e.ReasoningText
+ case e.Reasoning != "":
+ working.Text = e.Reasoning
+ }
+ if details := strings.TrimSpace(string(e.ReasoningDetails)); strings.HasPrefix(details, "[") && details != "[]" {
+ working.Details = append(json.RawMessage(nil), e.ReasoningDetails...)
+ }
+ return working
+}
+
+// options are the funnel's per-call settings for everything the SDK request
+// has a field for: the tools and the choice among them, the output ceiling,
+// the temperature and the response format. The model is set by the caller,
+// once it is decided ([Resolve]).
+func (r chatRequest) options() ([]ai.Option, error) {
+ var options []ai.Option
+ if len(r.Tools) > 0 {
+ tools := make([]ai.ToolDefinition, 0, len(r.Tools))
+ for index, tool := range r.Tools {
+ kind := strings.TrimSpace(tool.Type)
+ if kind == "" {
+ kind = "function"
+ }
+ if kind != "function" {
+ return nil, fmt.Errorf("tool %d is a %q tool; the model API carries function tools", index, kind)
+ }
+ if strings.TrimSpace(tool.Function.Name) == "" {
+ return nil, fmt.Errorf("tool %d has no name", index)
+ }
+ parameters := tool.Function.Parameters
+ if parameters == nil {
+ parameters = map[string]any{"type": "object", "properties": map[string]any{}}
+ }
+ tools = append(tools, ai.ToolDefinition{Type: "function", Function: ai.ToolFunction{
+ Name: tool.Function.Name, Description: tool.Function.Description, Parameters: parameters,
+ }})
+ }
+ options = append(options, ai.WithTools(tools))
+ // The SDK's WithTools says "auto"; a choice the program made is
+ // applied after it, so the program's word is the one that travels.
+ if choice, ok := decodeToolChoice(r.ToolChoice); ok {
+ options = append(options, withToolChoice(choice))
+ }
+ }
+ if ceiling := firstCeiling(r.MaxCompletionTokens, r.MaxTokens); ceiling > 0 {
+ options = append(options, ai.WithMaxTokens(ceiling))
+ }
+ if r.Temperature != nil {
+ options = append(options, ai.WithTemperature(*r.Temperature))
+ }
+ format, err := decodeResponseFormat(r.ResponseFormat)
+ if err != nil {
+ return nil, err
+ }
+ if format != nil {
+ options = append(options, withResponseFormat(format))
+ }
+ return options, nil
+}
+
+// firstCeiling is the output ceiling a request named: max_completion_tokens,
+// OpenAI's newer spelling, when it is there, and max_tokens otherwise. The
+// provider decides which of the two a given endpoint is sent.
+func firstCeiling(ceilings ...*int) int {
+ for _, ceiling := range ceilings {
+ if ceiling != nil && *ceiling > 0 {
+ return *ceiling
+ }
+ }
+ return 0
+}
+
+// decodeToolChoice reads tool_choice as the program wrote it: one of the
+// three words, or an object naming a function.
+func decodeToolChoice(raw json.RawMessage) (any, bool) {
+ text := strings.TrimSpace(string(raw))
+ if text == "" || text == "null" {
+ return nil, false
+ }
+ var word string
+ if json.Unmarshal(raw, &word) == nil {
+ word = strings.TrimSpace(word)
+ return word, word != ""
+ }
+ var object map[string]any
+ if json.Unmarshal(raw, &object) == nil && len(object) > 0 {
+ return object, true
+ }
+ return nil, false
+}
+
+// decodeResponseFormat reads response_format. `text` is the default and is
+// sent as nothing; json_object and json_schema travel in the SDK's own shape.
+func decodeResponseFormat(raw json.RawMessage) (*ai.ResponseFormat, error) {
+ text := strings.TrimSpace(string(raw))
+ if text == "" || text == "null" {
+ return nil, nil
+ }
+ var format ai.ResponseFormat
+ if err := json.Unmarshal(raw, &format); err != nil {
+ return nil, fmt.Errorf("response_format does not parse: %v", err)
+ }
+ switch strings.TrimSpace(format.Type) {
+ case "", "text":
+ return nil, nil
+ case "json_object":
+ return &ai.ResponseFormat{Type: "json_object"}, nil
+ case "json_schema":
+ if format.JSONSchema == nil || len(format.JSONSchema.Schema) == 0 {
+ return nil, errors.New("response_format json_schema carries no schema")
+ }
+ return &format, nil
+ default:
+ return nil, fmt.Errorf("response_format %q is not one the model API carries", format.Type)
+ }
+}
+
+// withToolChoice sets the program's own tool_choice on the SDK request.
+func withToolChoice(choice any) ai.Option {
+ return func(request *ai.Request) error {
+ request.ToolChoice = choice
+ return nil
+ }
+}
+
+// withResponseFormat sets a decoded response_format on the SDK request.
+func withResponseFormat(format *ai.ResponseFormat) ai.Option {
+ return func(request *ai.Request) error {
+ request.ResponseFormat = format
+ return nil
+ }
+}
+
+// depth is the reasoning depth the program asked for — OpenRouter's
+// `reasoning` object or OpenAI's `reasoning_effort` — on codeaf's own ladder:
+// low, medium and high are the words every provider shares, and xhigh and max
+// are the two rungs above them, which codeaf says with a thinking budget
+// (internal/provider's effortladder.go). `enabled: false` and `none` switch the
+// pass off, and `minimal` is the router's own lowest word. A word none of that
+// has a place for is not sent, because a knob a model would refuse must never
+// reach the wire.
+func (r chatRequest) depth() depth {
+ word := strings.TrimSpace(r.ReasoningEffort)
+ if r.Reasoning != nil {
+ if r.Reasoning.Enabled != nil && !*r.Reasoning.Enabled {
+ return depth{word: provider.EffortOff}
+ }
+ if said := strings.TrimSpace(r.Reasoning.Effort); said != "" {
+ word = said
+ }
+ }
+ switch word = strings.ToLower(word); word {
+ case "none", "off":
+ return depth{word: provider.EffortOff}
+ case "minimal":
+ return depth{word: provider.EffortMinimal}
+ }
+ if rung := effort.Rung(word); rung.Valid() {
+ return depth{rung: rung}
+ }
+ return depth{}
+}
+
+// ── the answer ──────────────────────────────────────────────────────────────
+
+// completion is one whole answer, the body a request that did not ask for a
+// stream is given.
+type completion struct {
+ ID string `json:"id"`
+ Provider string `json:"provider,omitempty"`
+ Model string `json:"model"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Choices []wholeChoice `json:"choices"`
+ Usage usageBlock `json:"usage"`
+}
+
+type wholeChoice struct {
+ Index int `json:"index"`
+ Message map[string]any `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ NativeFinishReason string `json:"native_finish_reason"`
+ Logprobs any `json:"logprobs"`
+}
+
+// chunk is one event of a streamed answer.
+type chunk struct {
+ ID string `json:"id"`
+ Provider string `json:"provider,omitempty"`
+ Model string `json:"model"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Choices []chunkChoice `json:"choices"`
+ Usage *usageBlock `json:"usage,omitempty"`
+ Error *errorDetail `json:"error,omitempty"`
+}
+
+type chunkChoice struct {
+ Index int `json:"index"`
+ Delta map[string]any `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+ NativeFinishReason *string `json:"native_finish_reason"`
+ Logprobs any `json:"logprobs"`
+}
+
+// usageBlock is OpenRouter's usage object. COST IS ALWAYS PRESENT, zero
+// included: a program that budgets reads it off every answer, and a missing
+// field is a budget that silently stops counting.
+type usageBlock struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ Cost float64 `json:"cost"`
+ PromptTokensDetails promptDetail `json:"prompt_tokens_details"`
+}
+
+type promptDetail struct {
+ CachedTokens int `json:"cached_tokens"`
+}
+
+// errorBody is OpenRouter's error envelope: a sentence and a numeric code.
+type errorBody struct {
+ Error errorDetail `json:"error"`
+}
+
+type errorDetail struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+}
+
+// wireToolCall is one tool call on the answer, with the index a stream's
+// delta carries so a client can assemble calls by position.
+type wireToolCall struct {
+ Index *int `json:"index,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+}
+
+// answer is everything one call came back with, in the shape both the whole
+// body and the stream are written from.
+type answer struct {
+ id string
+ provider string
+ model string
+ created int64
+ text string
+ calls []ai.ToolCall
+ finish string
+ reasoning captured
+ usage usageBlock
+}
+
+// finishOf is the answer's own word for how it ended, "tool_calls" for an
+// answer that is a tool call and said nothing, "stop" when nothing was said.
+func finishOf(response *ai.Response) string {
+ finish := strings.TrimSpace(provider.FinishReason(response))
+ if finish != "" {
+ return finish
+ }
+ if response != nil && response.HasToolCalls() {
+ return "tool_calls"
+ }
+ return "stop"
+}
+
+// toolCalls is the answer's tool calls in the wire's shape, indexed when the
+// shape is a stream's.
+func toolCalls(calls []ai.ToolCall, indexed bool) []wireToolCall {
+ out := make([]wireToolCall, 0, len(calls))
+ for position, call := range calls {
+ wired := wireToolCall{ID: call.ID, Type: "function"}
+ if strings.TrimSpace(call.Type) != "" {
+ wired.Type = call.Type
+ }
+ wired.Function.Name = call.Function.Name
+ wired.Function.Arguments = call.Function.Arguments
+ if indexed {
+ at := position
+ wired.Index = &at
+ }
+ out = append(out, wired)
+ }
+ return out
+}
+
+// message is the whole answer's assistant message, the model's working on it
+// under the router's own `reasoning` ([captured.onto] says why).
+func (a answer) message() map[string]any {
+ message := map[string]any{"role": "assistant", "refusal": nil}
+ if a.text != "" || len(a.calls) == 0 {
+ message["content"] = a.text
+ } else {
+ message["content"] = nil
+ }
+ if len(a.calls) > 0 {
+ message["tool_calls"] = toolCalls(a.calls, false)
+ }
+ a.reasoning.onto(message)
+ return message
+}
+
+// whole is the answer as one completion body.
+func (a answer) whole() completion {
+ return completion{
+ ID: a.id, Provider: a.provider, Model: a.model, Object: "chat.completion", Created: a.created,
+ Choices: []wholeChoice{{Index: 0, Message: a.message(), FinishReason: a.finish, NativeFinishReason: a.finish}},
+ Usage: a.usage,
+ }
+}
+
+// chunks is the answer as the events of a stream, in the order OpenRouter
+// sends them: the working, the words, each tool call whole under its index, the
+// finish, and then the usage on a chunk of its own — the last thing before
+// `[DONE]`, which is where a program that budgets reads its cost.
+func (a answer) chunks() []chunk {
+ head := func(delta map[string]any, finish *string) chunk {
+ return chunk{
+ ID: a.id, Provider: a.provider, Model: a.model, Object: "chat.completion.chunk", Created: a.created,
+ Choices: []chunkChoice{{Index: 0, Delta: delta, FinishReason: finish, NativeFinishReason: finish}},
+ }
+ }
+ var out []chunk
+ if a.reasoning.present() {
+ delta := map[string]any{"role": "assistant", "content": ""}
+ a.reasoning.onto(delta)
+ out = append(out, head(delta, nil))
+ }
+ if a.text != "" || len(a.calls) == 0 {
+ out = append(out, head(map[string]any{"role": "assistant", "content": a.text}, nil))
+ }
+ for _, call := range toolCalls(a.calls, true) {
+ out = append(out, head(map[string]any{"role": "assistant", "content": nil, "tool_calls": []wireToolCall{call}}, nil))
+ }
+ finish := a.finish
+ out = append(out, head(map[string]any{"role": "assistant", "content": ""}, &finish))
+ usage := a.usage
+ last := head(map[string]any{"role": "assistant", "content": ""}, nil)
+ last.Usage = &usage
+ return append(out, last)
+}
+
+// failedChunk is a failure after the stream has begun: OpenRouter's in-band
+// error event, an `error` beside a choice that finished on "error".
+func failedChunk(id, model string, created int64, status int, message string) chunk {
+ finish := "error"
+ return chunk{
+ ID: id, Model: model, Object: "chat.completion.chunk", Created: created,
+ Error: &errorDetail{Message: message, Code: status},
+ Choices: []chunkChoice{{Index: 0, Delta: map[string]any{"content": ""}, FinishReason: &finish, NativeFinishReason: &finish}},
+ }
+}
diff --git a/internal/provider/modelapi/wire_test.go b/internal/provider/modelapi/wire_test.go
new file mode 100644
index 0000000000..fe0b2ee073
--- /dev/null
+++ b/internal/provider/modelapi/wire_test.go
@@ -0,0 +1,295 @@
+package modelapi
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/effort"
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// applied is the SDK request a call's options build, which is exactly what the
+// funnel encodes.
+func applied(t *testing.T, decoded *call) ai.Request {
+ t.Helper()
+ var request ai.Request
+ for _, option := range decoded.options {
+ if err := option(&request); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return request
+}
+
+// THE BODY A PROGRAM SENDS IS THE REQUEST THE FUNNEL MAKES: messages of every
+// role with their tool calls and results, the tools, the program's own
+// tool_choice over the SDK's default, the output ceiling in either spelling,
+// the temperature, the response format, the reasoning depth, the cache key and
+// the working handed back on an assistant message.
+func TestTheWireCarriesEveryFieldTheFunnelHasAHomeFor(t *testing.T) {
+ body := `{
+ "model": "openrouter/deepseek/deepseek-v4-flash-0731",
+ "messages": [
+ {"role": "developer", "content": "you are careful"},
+ {"role": "user", "content": [{"type": "text", "text": "fix the test"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]},
+ {"role": "assistant", "content": null, "reasoning": "look first", "reasoning_details": [{"type": "reasoning.text", "text": "look first"}],
+ "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "bash", "arguments": "{\"cmd\":\"go test\"}"}}]},
+ {"role": "tool", "tool_call_id": "call_1", "content": "ok"}
+ ],
+ "tools": [{"type": "function", "function": {"name": "bash", "description": "run a command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}}}],
+ "tool_choice": {"type": "function", "function": {"name": "bash"}},
+ "max_tokens": 900,
+ "max_completion_tokens": 1200,
+ "temperature": 0.2,
+ "reasoning": {"effort": "high"},
+ "prompt_cache_key": "thread-a",
+ "response_format": {"type": "json_schema", "json_schema": {"name": "verdict", "strict": true, "schema": {"type": "object"}}},
+ "provider": {"order": ["somebody"]},
+ "stream": true,
+ "stream_options": {"include_usage": true},
+ "usage": {"include": true}
+ }`
+ decoded, err := decodeRequest([]byte(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decoded.asked != "openrouter/deepseek/deepseek-v4-flash-0731" || decoded.cacheKey != "thread-a" || decoded.thread != "thread-a" || !decoded.stream {
+ t.Fatalf("call = %+v", decoded)
+ }
+ if len(decoded.messages) != 4 || decoded.messages[0].Role != "system" {
+ t.Fatalf("messages = %+v, want four with the developer turn read as system", decoded.messages)
+ }
+ if parts := decoded.messages[1].Content; len(parts) != 2 || parts[1].ImageURL == nil {
+ t.Fatalf("the user's picture was lost: %+v", parts)
+ }
+ assistant := decoded.messages[2]
+ if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].Function.Name != "bash" || assistant.ToolCalls[0].Function.Arguments != `{"cmd":"go test"}` {
+ t.Fatalf("assistant tool calls = %+v", assistant.ToolCalls)
+ }
+ if tool := decoded.messages[3]; tool.Role != "tool" || tool.ToolCallID != "call_1" || tool.Content[0].Text != "ok" {
+ t.Fatalf("tool result = %+v", tool)
+ }
+ // The router's own `reasoning` is left unnamed here: the call names it
+ // from its thread (threads.name).
+ if len(decoded.reasoning) != 4 || decoded.reasoning[2].Field != "" || decoded.reasoning[2].Text != "look first" ||
+ !strings.Contains(string(decoded.reasoning[2].Details), "reasoning.text") || decoded.reasoning[0].Text != "" {
+ t.Fatalf("working sidecar = %+v, want the assistant's working aligned with its message", decoded.reasoning)
+ }
+ if decoded.depth != (depth{rung: effort.High}) {
+ t.Fatalf("depth = %+v, want the high rung", decoded.depth)
+ }
+ request := applied(t, decoded)
+ if len(request.Tools) != 1 || request.Tools[0].Function.Name != "bash" || request.Tools[0].Function.Parameters["type"] != "object" {
+ t.Fatalf("tools = %+v", request.Tools)
+ }
+ choice, ok := request.ToolChoice.(map[string]any)
+ if !ok || choice["type"] != "function" {
+ t.Fatalf("tool_choice = %#v, want the program's own object over the SDK's auto", request.ToolChoice)
+ }
+ if request.MaxTokens == nil || *request.MaxTokens != 1200 {
+ t.Fatalf("max tokens = %v, want max_completion_tokens' 1200", request.MaxTokens)
+ }
+ if request.Temperature == nil || *request.Temperature != 0.2 {
+ t.Fatalf("temperature = %v", request.Temperature)
+ }
+ if request.ResponseFormat == nil || request.ResponseFormat.Type != "json_schema" || request.ResponseFormat.JSONSchema.Name != "verdict" ||
+ !request.ResponseFormat.JSONSchema.Strict || string(request.ResponseFormat.JSONSchema.Schema) != `{"type": "object"}` {
+ t.Fatalf("response_format = %+v", request.ResponseFormat)
+ }
+}
+
+// EVERY SPELLING OF A DEPTH LANDS ON CODEAF'S OWN LADDER: the three shared
+// words and the two rungs above them as rungs — senior-dev's `--variant xhigh`
+// included — the pass switched off and the router's lowest word as the
+// adapter's own words, and anything else as nothing.
+func TestTheWireReadsEveryReasoningSpelling(t *testing.T) {
+ for _, row := range []struct {
+ body string
+ want depth
+ }{
+ {`"reasoning_effort": "low"`, depth{rung: effort.Low}},
+ {`"reasoning": {"effort": "medium"}`, depth{rung: effort.Medium}},
+ {`"reasoning": {"effort": "xhigh"}`, depth{rung: effort.XHigh}},
+ {`"reasoning_effort": "max"`, depth{rung: effort.Max}},
+ {`"reasoning": {"effort": "minimal"}`, depth{word: provider.EffortMinimal}},
+ {`"reasoning": {"enabled": false}`, depth{word: provider.EffortOff}},
+ {`"reasoning_effort": "none"`, depth{word: provider.EffortOff}},
+ {`"reasoning": {"enabled": true}`, depth{}},
+ // A word nothing in codeaf has a place for is never sent.
+ {`"reasoning_effort": "ultra"`, depth{}},
+ } {
+ decoded, err := decodeRequest([]byte(`{"model":"m","messages":[{"role":"user","content":"hi"}],` + row.body + `}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decoded.depth != row.want {
+ t.Fatalf("%s: depth %+v, want %+v", row.body, decoded.depth, row.want)
+ }
+ }
+}
+
+func TestTheWireRefusesWhatItCannotCarryInOneSentence(t *testing.T) {
+ for _, row := range []struct{ body, says string }{
+ {`not json`, "not a chat-completions request"},
+ {`{"model":"m","messages":[]}`, "no messages"},
+ {`{"model":"m","messages":[{"role":"wizard","content":"hi"}]}`, `"wizard"`},
+ {`{"model":"m","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"retrieval"}]}`, "function tools"},
+ {`{"model":"m","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_schema"}}`, "no schema"},
+ } {
+ if _, err := decodeRequest([]byte(row.body)); err == nil || !strings.Contains(err.Error(), row.says) {
+ t.Fatalf("%s: err = %v, want it to say %q", row.body, err, row.says)
+ }
+ }
+ // A response_format of text is the default and travels as nothing; no
+ // tools means no tool_choice either.
+ decoded, err := decodeRequest([]byte(`{"messages":[{"role":"user","content":"hi"}],"response_format":{"type":"text"},"tool_choice":"required"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if request := applied(t, decoded); request.ResponseFormat != nil || request.ToolChoice != nil || request.Tools != nil {
+ t.Fatalf("request = %+v", request)
+ }
+}
+
+// THE ANSWER IS THE ROUTER'S OWN SHAPE, and a stream ends the way the
+// router's does: the working, the words, each tool call under its index, the
+// finish, a usage chunk carrying the cost, in that order.
+func TestAStreamedAnswerIsTheRoutersChunksInTheRoutersOrder(t *testing.T) {
+ said := answer{
+ id: "gen-1", model: "m", created: 7, text: "done",
+ calls: []ai.ToolCall{{ID: "call_1", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}, {ID: "call_2", Function: ai.ToolCallFunction{Name: "edit", Arguments: `{"a":1}`}}},
+ finish: "tool_calls",
+ reasoning: captured{field: "reasoning_content", text: "thinking", details: json.RawMessage(`[{"type":"reasoning.text","text":"thinking"}]`)},
+ usage: usageBlock{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, Cost: 0.25, PromptTokensDetails: promptDetail{CachedTokens: 4}},
+ }
+ chunks := said.chunks()
+ if len(chunks) != 6 {
+ t.Fatalf("%d chunks, want working, words, two calls, finish and usage", len(chunks))
+ }
+ // Working that arrived as a direct endpoint's reasoning_content is handed
+ // out under the router's own name, the one a program reads.
+ if working := chunks[0].Choices[0].Delta; working["reasoning"] != "thinking" || working["reasoning_details"] == nil || working["reasoning_content"] != nil {
+ t.Fatalf("working chunk = %+v, want the working under the router's own name", working)
+ }
+ if chunks[1].Choices[0].Delta["content"] != "done" {
+ t.Fatalf("words chunk = %+v", chunks[1].Choices[0].Delta)
+ }
+ second := chunks[3].Choices[0].Delta["tool_calls"].([]wireToolCall)[0]
+ if second.Index == nil || *second.Index != 1 || second.ID != "call_2" || second.Type != "function" || second.Function.Name != "edit" || second.Function.Arguments != `{"a":1}` {
+ t.Fatalf("second call = %+v", second)
+ }
+ if finish := chunks[4].Choices[0].FinishReason; finish == nil || *finish != "tool_calls" || chunks[4].Usage != nil {
+ t.Fatalf("finish chunk = %+v", chunks[4])
+ }
+ if last := chunks[5]; last.Usage == nil || last.Usage.Cost != 0.25 || last.Usage.PromptTokensDetails.CachedTokens != 4 || last.Choices[0].FinishReason != nil {
+ t.Fatalf("usage chunk = %+v", last)
+ }
+ encoded, _ := json.Marshal(chunks[5])
+ for _, want := range []string{`"object":"chat.completion.chunk"`, `"cost":0.25`, `"prompt_tokens_details":{"cached_tokens":4}`, `"finish_reason":null`} {
+ if !strings.Contains(string(encoded), want) {
+ t.Fatalf("usage chunk %s lacks %s", encoded, want)
+ }
+ }
+}
+
+func TestAWholeAnswerCarriesItsCallsAndACostOfZeroOutLoud(t *testing.T) {
+ said := answer{id: "gen-2", model: "m", created: 9, calls: []ai.ToolCall{{ID: "c", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}, finish: "tool_calls"}
+ encoded, err := json.Marshal(said.whole())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{`"object":"chat.completion"`, `"content":null`, `"tool_calls":[{"id":"c","type":"function"`, `"finish_reason":"tool_calls"`, `"cost":0`} {
+ if !strings.Contains(string(encoded), want) {
+ t.Fatalf("whole answer %s lacks %s", encoded, want)
+ }
+ }
+ if strings.Contains(string(encoded), `"tool_calls":[{"index"`) {
+ t.Fatalf("a whole answer's tool calls carry a stream's index: %s", encoded)
+ }
+}
+
+// A THREAD REMEMBERS ITS PREVIOUS REQUEST AND NOTHING ELSE: what is new is the
+// delta, the model's own replies are never sent words, and a request that is
+// not the previous one extended is a restart.
+func TestAThreadRecordsOnlyWhatItHadNotSaidBefore(t *testing.T) {
+ system := ai.Message{Role: "system", Content: []ai.ContentPart{{Type: "text", Text: "rules"}}}
+ user := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "fix it"}}}
+ reply := ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "c1", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}}
+ result := ai.Message{Role: "tool", ToolCallID: "c1", Content: []ai.ContentPart{{Type: "text", Text: "PASS"}}}
+ var memory threads
+ sent, restarted := memory.delta("main", []ai.Message{system, user})
+ if restarted || len(sent) != 2 || sent[0].Role != "system" || sent[1].Text != "fix it" {
+ t.Fatalf("first call sent %+v restarted %v", sent, restarted)
+ }
+ sent, restarted = memory.delta("main", []ai.Message{system, user, reply, result})
+ if restarted || len(sent) != 1 || sent[0].Role != "tool" || sent[0].Tool != "bash" || sent[0].Text != "PASS" {
+ t.Fatalf("second call sent %+v restarted %v, want only the tool's result, naming its tool", sent, restarted)
+ }
+ // The same request again — a retry — added nothing.
+ if sent, restarted = memory.delta("main", []ai.Message{system, user, reply, result}); restarted || len(sent) != 0 {
+ t.Fatalf("a retry sent %+v restarted %v", sent, restarted)
+ }
+ summary := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "so far: tests pass"}}}
+ sent, restarted = memory.delta("main", []ai.Message{system, summary})
+ if !restarted || len(sent) != 2 || sent[1].Text != "so far: tests pass" {
+ t.Fatalf("a rewritten history sent %+v restarted %v, want the whole of it and the restart said", sent, restarted)
+ }
+ // Another thread has its own memory.
+ if sent, restarted = memory.delta("helper", []ai.Message{system, user}); restarted || len(sent) != 2 {
+ t.Fatalf("a second thread sent %+v restarted %v, want its own first call", sent, restarted)
+ }
+}
+
+// WORKING HANDED BACK UNDER THE ROUTER'S NAME GOES BACK UNDER THE FIELD IT
+// ARRIVED ON: a thread whose working came in as reasoning_content has it
+// replayed as reasoning_content; a thread that has not said is the router's;
+// a field the program named outright is kept; and the threads do not share.
+func TestHandedBackWorkingIsNamedByWhatItsThreadLastArrivedOn(t *testing.T) {
+ var memory threads
+ memory.arrived("coder", "reasoning_content")
+ memory.arrived("coder", "")
+ working := func() []provider.MessageReasoning {
+ return []provider.MessageReasoning{{}, {Text: "mine"}, {Field: "reasoning_text", Text: "named"}, {Details: json.RawMessage(`[{}]`)}}
+ }
+ named := memory.name("coder", working())
+ if named[1].Field != "reasoning_content" || named[2].Field != "reasoning_text" || named[0].Field != "" || named[3].Field != "" {
+ t.Fatalf("named on the coder's thread = %+v", named)
+ }
+ if other := memory.name("helper", working()); other[1].Field != "reasoning" {
+ t.Fatalf("a thread that has not said named its working %q, want the router's own", other[1].Field)
+ }
+}
+
+func TestAMessageThatIsNotTextIsNamedInBrackets(t *testing.T) {
+ message := ai.Message{Role: "user", Content: []ai.ContentPart{
+ {Type: "text", Text: "look"}, {Type: "image_url", ImageURL: &ai.ImageURLData{URL: "x"}}, {Type: "file"},
+ }}
+ if got := said(message, nil).Text; got != "look\n[image]\n[file]" {
+ t.Fatalf("said %q", got)
+ }
+}
+
+func TestStreamedWorkingIsJoinedAsTheWireSentIt(t *testing.T) {
+ var held json.RawMessage
+ held = joinArrays(held, json.RawMessage(`[{"a":1}]`))
+ held = joinArrays(held, json.RawMessage(` [] `))
+ held = joinArrays(held, json.RawMessage(`not an array`))
+ held = joinArrays(held, json.RawMessage(`[{"b":2},{"c":3}]`))
+ if string(held) != `[{"a":1},{"b":2},{"c":3}]` {
+ t.Fatalf("joined %s", held)
+ }
+ catch := &catcher{}
+ catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "one ", ReasoningField: "reasoning"})
+ catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "shown only", FromAnswer: true})
+ catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "two", ReasoningDetails: json.RawMessage(`[{"t":1}]`)})
+ if got := catch.caught(); got.field != "reasoning" || got.text != "one two" || string(got.details) != `[{"t":1}]` {
+ t.Fatalf("caught %+v", got)
+ }
+ // A replaced answer takes its working with it.
+ catch.observe(provider.StreamEvent{Kind: provider.StreamReplaced, Delta: "retrying"})
+ if got := catch.caught(); got.present() {
+ t.Fatalf("working survived its answer's replacement: %+v", got)
+ }
+}
diff --git a/internal/provider/modelapi/working.go b/internal/provider/modelapi/working.go
new file mode 100644
index 0000000000..a2f46502b8
--- /dev/null
+++ b/internal/provider/modelapi/working.go
@@ -0,0 +1,118 @@
+package modelapi
+
+// The model's working on one answer, caught as the funnel streams it.
+//
+// The SDK's response has no field for reasoning, so the only place the words a
+// thinking model wrote before its answer arrive is the funnel's stream
+// observer (provider.StreamReasoning). They are gathered here and handed to the
+// program on its answer, because a thinking model in a tool loop is continued
+// by being handed its own working back — and a program can only hand back what
+// it was given.
+//
+// NOTHING IS FORWARDED WHILE IT ARRIVES. The funnel can replace an answer it
+// has begun (a stalled stream rescued by a second request, provider's
+// StreamReplaced), and a program cannot be told to forget bytes it has already
+// read; so the working is kept until the answer is final and a replacement
+// empties it.
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/provider"
+)
+
+// captured is one answer's working: the field it arrived on, its words, and the
+// structured blocks a router sends beside them.
+type captured struct {
+ field string
+ text string
+ details json.RawMessage
+}
+
+// present reports whether there is any working to hand over.
+func (c captured) present() bool { return c.text != "" || len(c.details) > 0 }
+
+// onto writes the working onto a message or a delta under THE ROUTER'S OWN
+// NAME, `reasoning`, whatever field it arrived on. A program written against
+// OpenRouter reads that name and no other, and a direct endpoint's
+// `reasoning_content` would be working it never saw and so could never hand
+// back; the field it really arrived on is remembered for the thread instead
+// ([threads.arrived]), and a hand-back is replayed under it.
+func (c captured) onto(target map[string]any) {
+ if c.text != "" {
+ target["reasoning"] = c.text
+ }
+ if len(c.details) > 0 {
+ target["reasoning_details"] = c.details
+ }
+}
+
+// catcher is the stream observer one call installs. The funnel calls it on its
+// own read loop, synchronously, so it does nothing but append under a lock.
+type catcher struct {
+ mu sync.Mutex
+ field string
+ text strings.Builder
+ details json.RawMessage
+}
+
+// observe is the provider.StreamObserver.
+func (c *catcher) observe(event provider.StreamEvent) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ switch event.Kind {
+ case provider.StreamReplaced:
+ // Everything gathered belonged to the answer being thrown away.
+ c.field, c.details = "", nil
+ c.text.Reset()
+ case provider.StreamReasoning:
+ // Working carved out of the answer's own text has no field to be
+ // handed back under (provider's answer.go), so it is not a
+ // continuation and is not kept.
+ if event.FromAnswer {
+ return
+ }
+ if c.field == "" && event.ReasoningField != "" {
+ c.field = event.ReasoningField
+ }
+ c.text.WriteString(event.Delta)
+ c.details = joinArrays(c.details, event.ReasoningDetails)
+ }
+}
+
+// caught is what the observer holds now.
+func (c *catcher) caught() captured {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return captured{field: c.field, text: c.text.String(), details: append(json.RawMessage(nil), c.details...)}
+}
+
+// joinArrays appends one streamed array of reasoning blocks to the blocks
+// already held, as the wire sent them: a client written for a router's stream
+// assembles them itself, exactly as it would have assembled that router's
+// chunks.
+func joinArrays(current, next json.RawMessage) json.RawMessage {
+ next = bytes.TrimSpace(next)
+ if len(next) < 2 || next[0] != '[' || next[len(next)-1] != ']' {
+ return current
+ }
+ inner := bytes.TrimSpace(next[1 : len(next)-1])
+ if len(inner) == 0 {
+ return current
+ }
+ if len(current) == 0 {
+ return append(json.RawMessage(nil), next...)
+ }
+ held := bytes.TrimSpace(current[1 : len(current)-1])
+ joined := make(json.RawMessage, 0, len(held)+len(inner)+3)
+ joined = append(joined, '[')
+ joined = append(joined, held...)
+ if len(held) > 0 {
+ joined = append(joined, ',')
+ }
+ joined = append(joined, inner...)
+ return append(joined, ']')
+}
diff --git a/internal/provider/receipt.go b/internal/provider/receipt.go
index a58f99f070..6557e459d6 100644
--- a/internal/provider/receipt.go
+++ b/internal/provider/receipt.go
@@ -38,9 +38,18 @@ const (
// The growing pauses give a generation receipt time to appear after its call
// ends. It is the only honest source of this money, and this bounded wait is
// entirely in the background, so generosity here costs the person nothing.
+ //
+ // THE FOURTH PAUSE IS THE MEASURED ONE. The receipts of the three stopped
+ // senior-dev runs of 2026-09-23 — each for the call in flight when the run
+ // was cut — landed 20.5, 20.6 and 20.8 seconds after the cut: on the fourth
+ // and then last request, with nothing to spare. A cancelled generation takes
+ // the router about that long to price, so a little more lag on its side
+ // turned a real charge into an unpriced marker. One more request twenty
+ // seconds later gives that ending a second chance.
receiptFirstRetryDelay = time.Second
receiptSecondRetryDelay = 4 * time.Second
receiptThirdRetryDelay = 15 * time.Second
+ receiptFourthRetryDelay = 20 * time.Second
// receiptRequestAllowance leaves each attempt room to complete in addition
// to the pauses. The ceiling is derived from every part of that schedule so
// widening one cannot silently leave the background context too short.
@@ -48,8 +57,17 @@ const (
// receiptScheduleSlack leaves the derived ceiling comfortably beyond both
// the growing pauses and every request's allowance.
receiptScheduleSlack = 5 * time.Second
- receiptFetchTimeout = receiptFirstRetryDelay + receiptSecondRetryDelay + receiptThirdRetryDelay +
+ receiptFetchTimeout = receiptFirstRetryDelay + receiptSecondRetryDelay + receiptThirdRetryDelay + receiptFourthRetryDelay +
time.Duration(receiptAttempts)*receiptRequestAllowance + receiptScheduleSlack
+ // ReceiptWait is the longest one receipt can take to be answered once it is
+ // queued: the whole schedule's ceiling, counted from the queue however long
+ // the receipt waited there for a worker ([receiptWork.deadline]), so a
+ // waiter that starts after every receipt it is owed was queued sees each one
+ // answered within it. It is exported for work that waits
+ // for the receipts it is owed before it closes its books
+ // ([WithReceiptPending]), so that wait and this schedule are one figure and
+ // widening the schedule widens the wait with it.
+ ReceiptWait = receiptFetchTimeout
// receiptRouteTTL is how long a base's answer that it has no generation
// route is trusted before the capability may be asked about again.
receiptRouteTTL = 5 * time.Minute
@@ -57,24 +75,50 @@ const (
// carries and still prevents an upstream body becoming an unbounded read.
maxReceiptBytes = 1 << 20
- // These two words name endings that have no [CutReason] of their own. They
+ // These words name endings that have no [CutReason] of their own. They
// live here so every such ending and every receipt row spell them alike.
- receiptTornReason = "torn"
- receiptRefusalReason = "refusal"
+ // unmetered is an answer that arrived whole with no usage block
+ // ([WithUnmeteredReceipts]).
+ receiptTornReason = "torn"
+ receiptRefusalReason = "refusal"
+ receiptUnmeteredReason = "unmetered"
)
var receiptRetrySchedule = [...]time.Duration{
receiptFirstRetryDelay,
receiptSecondRetryDelay,
receiptThirdRetryDelay,
+ receiptFourthRetryDelay,
}
// receiptWork is all the worker may retain from a call whose own context is
// usually cancelled. The sink and attribution are values; no request context
// crosses the hand-off because its cancellation is why this work exists.
+// queued is the instant the receipt was owed, which its ceiling is counted from.
type receiptWork struct {
result Reconciled
sink ReconcileSink
+ queued time.Time
+}
+
+// deadline is the latest a receipt may be answered: [receiptFetchTimeout] after
+// it was queued.
+//
+// IT IS COUNTED FROM THE QUEUE AND NOT FROM THE WORKER, because the queue is
+// what a waiter sees. A client drains its receipts with [receiptWorkerCount]
+// workers, so a fifth receipt owed behind four slow ones started its whole
+// schedule some forty seconds late and was answered about eighty seconds after
+// it was queued — past [ReceiptWait], so a run that waited that long for its
+// books closed them without that call's price. A receipt that waited in the
+// queue loses none of its chance by this: the provider was pricing its
+// generation the whole time it waited, and the worker's first request for it
+// is made that much later.
+func (w receiptWork) deadline() time.Time {
+ queued := w.queued
+ if queued.IsZero() {
+ queued = time.Now()
+ }
+ return queued.Add(receiptFetchTimeout)
}
// receiptRouteMemo remembers only the one definite capability answer: a base
@@ -150,11 +194,24 @@ func (c *Client) settle(ctx context.Context, model string, response *ai.Response
return
}
work := receiptWork{result: result, sink: sink}
+ // THE WORK IS TOLD A RECEIPT IS OWED BEFORE IT IS QUEUED, and told it was
+ // answered only after the sink has banked it, so a caller waiting for its
+ // receipts cannot see zero owed while money is between the two
+ // ([ReceiptPending]).
+ if pending := receiptPendingFrom(ctx); pending != nil {
+ done := pending()
+ work.sink = func(answer Reconciled) {
+ defer done()
+ sink(answer)
+ }
+ }
+ // THE RECEIPT'S BOUND STARTS HERE, when it is owed, and not when a worker
+ // gets to it ([receiptWork.deadline]).
+ work.queued = time.Now()
if !c.queueReceipt(work) {
// A full queue reports the missing price without holding up the turn.
- sink(result)
+ work.sink(result)
}
-
}
// runReceipts is one member of the small fixed pool draining this client's
@@ -203,7 +260,8 @@ func (c *Client) nextReceipt() (receiptWork, bool) {
// reconcile follows the fixed growing schedule and delivers exactly one answer.
// It starts from a fresh context because the call's own context has commonly
-// been cancelled already, then puts one ceiling around the entire schedule.
+// been cancelled already, then puts one ceiling around the entire schedule,
+// counted from when the receipt was queued ([receiptWork.deadline]).
func (c *Client) reconcile(work receiptWork) {
result := work.result
base := strings.TrimRight(strings.TrimSpace(c.config.BaseURL), "/")
@@ -211,7 +269,7 @@ func (c *Client) reconcile(work receiptWork) {
work.sink(result)
return
}
- ctx, cancel := context.WithTimeout(context.Background(), receiptFetchTimeout)
+ ctx, cancel := context.WithDeadline(context.Background(), work.deadline())
defer cancel()
for attempt := 0; attempt < receiptAttempts; attempt++ {
billed, found, noRoute := c.fetchReceipt(ctx, result.Ref)
diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go
index ec3fd255a1..4c1cb7d1a9 100644
--- a/internal/provider/receipt_test.go
+++ b/internal/provider/receipt_test.go
@@ -676,3 +676,266 @@ func TestReceiptWorkersRetireAfterTheirQueueDrains(t *testing.T) {
t.Fatal("a receipt arriving after retirement never restarted its worker")
}
}
+
+// TestAQueuedReceiptIsOwedUntilItsSinkHasBankedIt pins the pending door that
+// lets a run wait for the price of the call it was cut in the middle of: the
+// work is told a receipt is owed before the fetch begins, and told it was
+// answered only after the sink has had the money — never the other way round,
+// or a caller could read its total in the gap. A call that queues no receipt
+// (a usage block, or no id and no text) owes nothing.
+func TestAQueuedReceiptIsOwedUntilItsSinkHasBankedIt(t *testing.T) {
+ release := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ <-release
+ fmt.Fprint(w, `{"data":{"total_cost":0.058,"tokens_prompt":52139,"tokens_completion":4895}}`)
+ }))
+ t.Cleanup(server.Close)
+ client := receiptTestClient(t, server)
+ var mu sync.Mutex
+ var owed int
+ var order []string
+ pending := func() func() {
+ mu.Lock()
+ owed++
+ mu.Unlock()
+ return func() {
+ mu.Lock()
+ owed--
+ order = append(order, "answered")
+ mu.Unlock()
+ }
+ }
+ results := make(chan Reconciled, 1)
+ ctx := WithReceiptPending(WithReconcile(t.Context(), func(result Reconciled) {
+ mu.Lock()
+ order = append(order, "banked")
+ mu.Unlock()
+ results <- result
+ }), pending)
+
+ // Neither of these queues a receipt, so neither is owed.
+ cost := 0.01
+ client.settle(ctx, "sim/model", &ai.Response{Usage: &ai.Usage{PromptTokens: 1, Cost: &cost}}, "stalled", 0)
+ client.settle(ctx, "sim/model", &ai.Response{}, "stalled", 0)
+ mu.Lock()
+ if owed != 0 {
+ mu.Unlock()
+ t.Fatalf("owed = %d after two calls that queued no receipt", owed)
+ }
+ mu.Unlock()
+
+ client.settle(ctx, "sim/model", &ai.Response{ID: "cut-in-flight"}, "stopped", 12)
+ mu.Lock()
+ if owed != 1 {
+ mu.Unlock()
+ t.Fatalf("owed = %d while the receipt is being fetched, want 1", owed)
+ }
+ mu.Unlock()
+ close(release)
+ if result := receiptResult(t, results); !result.Found || result.Cost != 0.058 {
+ t.Fatalf("receipt = %+v", result)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ mu.Lock()
+ settled := owed == 0 && len(order) == 2
+ got := append([]string(nil), order...)
+ mu.Unlock()
+ if settled {
+ if got[0] != "banked" || got[1] != "answered" {
+ t.Fatalf("order = %v, want the money banked before the receipt is marked answered", got)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("the receipt was never marked answered: order %v", got)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+// writeWholeWithoutUsage streams a whole, finished answer that names its
+// generation and never sends a usage block.
+func writeWholeWithoutUsage(w http.ResponseWriter, id string) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `data: {"id":%q,"choices":[{"index":0,"delta":{"content":"a whole answer"}}]}`+"\n\n", id)
+ fmt.Fprintf(w, `data: {"id":%q,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\n", id)
+ fmt.Fprint(w, "data: [DONE]\n\n")
+ w.(http.Flusher).Flush()
+}
+
+// TestAnAnswerWithNoUsageIsSettledOnlyWhereTheWorkAskedForIt pins the opt-in
+// door: a whole answer that carried no usage block is settled like a cut one —
+// its receipt asked for and banked — only when the work armed
+// WithUnmeteredReceipts, and never on a direct service. Every other caller's
+// road is exactly what it was: no receipt request and nothing reported.
+func TestAnAnswerWithNoUsageIsSettledOnlyWhereTheWorkAskedForIt(t *testing.T) {
+ for _, row := range []struct {
+ name string
+ armed bool
+ direct bool
+ want bool
+ }{
+ {name: "armed on the routed service", armed: true, want: true},
+ {name: "not armed", armed: false},
+ {name: "armed on a direct service", armed: true, direct: true},
+ } {
+ t.Run(row.name, func(t *testing.T) {
+ var receipts atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/generation" {
+ receipts.Add(1)
+ fmt.Fprint(w, `{"data":{"total_cost":0.032296144,"tokens_prompt":116000,"tokens_completion":40}}`)
+ return
+ }
+ writeWholeWithoutUsage(w, "gen-unmetered")
+ }))
+ t.Cleanup(server.Close)
+ client, err := NewClient(Config{
+ APIKey: "receipt-key", BaseURL: server.URL, Model: "moonshotai/kimi-k2.6",
+ Direct: row.direct, HTTPClient: server.Client(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ client.velocity = newVelocityLedger()
+ client.wait = func(context.Context, time.Duration) error { return nil }
+ results := make(chan Reconciled, 1)
+ ctx := WithReconcile(WithStreamObserver(t.Context(), func(StreamEvent) {}), func(result Reconciled) { results <- result })
+ if row.armed {
+ ctx = WithUnmeteredReceipts(ctx)
+ }
+ response, err := client.CompleteWithMessages(ctx, userMessages("hello"))
+ if err != nil || response == nil || response.Usage != nil {
+ t.Fatalf("response %+v err %v, want a whole answer with no usage block", response, err)
+ }
+ if !row.want {
+ select {
+ case result := <-results:
+ t.Fatalf("an answer this work did not arm was settled: %+v", result)
+ case <-time.After(200 * time.Millisecond):
+ }
+ if receipts.Load() != 0 {
+ t.Fatalf("a receipt was asked for %d times", receipts.Load())
+ }
+ return
+ }
+ result := receiptResult(t, results)
+ if !result.Found || result.Cost != 0.032296144 || result.Ref != "gen-unmetered" || result.Reason != "unmetered" {
+ t.Fatalf("settled = %+v, want the receipt's $0.032296144", result)
+ }
+ })
+ }
+}
+
+// TestACutAnswerWithNoUsageIsStillSettledWhereTheWorkAskedForIt pins the opt-in
+// door on the roads that end in a cut. A paid 200 with no usage block that is
+// cut after it arrived — the model's own tool grammar written as text, or a
+// rescue that is not language — was billed through the ordinary door, which
+// banks nothing without a usage block, so on work that armed
+// WithUnmeteredReceipts the provider's charge reached no book at all.
+func TestACutAnswerWithNoUsageIsStillSettledWhereTheWorkAskedForIt(t *testing.T) {
+ const leak = `<|DSML|_web_search>{\"query\":\"x\"}<|/DSML|_web_search>`
+ for _, row := range []struct {
+ name string
+ stream bool
+ rescue bool
+ body string
+ }{
+ {name: "a whole answer that leaked its grammar", body: `{"id":"gen-cut","model":"sim/model","choices":[{"index":0,` +
+ `"finish_reason":"stop","message":{"role":"assistant","content":"` + leak + `"}}]}`},
+ {name: "a streamed answer that leaked its grammar", stream: true,
+ body: `data: {"id":"gen-cut","choices":[{"index":0,"delta":{"content":"` + leak + `"},"finish_reason":"stop"}]}` + "\n\n" + "data: [DONE]\n\n"},
+ {name: "a rescue that is not language", stream: true, rescue: true,
+ body: `data: {"id":"gen-cut","choices":[{"index":0,"delta":{"content":"half an answer \ufffd\ufffd"},"finish_reason":"stop"}]}` + "\n\n" + "data: [DONE]\n\n"},
+ } {
+ t.Run(row.name, func(t *testing.T) {
+ forgetLanes(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/generation" {
+ fmt.Fprint(w, `{"data":{"total_cost":0.0125,"tokens_prompt":9000,"tokens_completion":40}}`)
+ return
+ }
+ if row.stream {
+ w.Header().Set("Content-Type", "text/event-stream")
+ } else {
+ w.Header().Set("Content-Type", "application/json")
+ }
+ fmt.Fprint(w, row.body)
+ }))
+ t.Cleanup(server.Close)
+ client, err := NewClient(Config{
+ APIKey: "receipt-key", BaseURL: server.URL, Model: "sim/model", HTTPClient: server.Client(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ client.velocity = newVelocityLedger()
+ client.wait = func(context.Context, time.Duration) error { return nil }
+ results := make(chan Reconciled, 1)
+ ctx := WithUnmeteredReceipts(WithReconcile(t.Context(), func(result Reconciled) { results <- result }))
+ if row.stream {
+ ctx = WithStreamObserver(ctx, func(StreamEvent) {})
+ }
+ if row.rescue {
+ ctx = withHedgeLane(ctx, "rescue")
+ }
+ _, err = client.CompleteWithMessages(ctx, userMessages("look this up"), ai.WithTools(machineryTools("web_search")))
+ if _, ok := CutFrom(err); !ok {
+ t.Fatalf("err = %v, want the answer cut", err)
+ }
+ result := receiptResult(t, results)
+ if !result.Found || result.Cost != 0.0125 || result.Ref != "gen-cut" {
+ t.Fatalf("settled = %+v, want the receipt's $0.0125", result)
+ }
+ })
+ }
+}
+
+// ReceiptWait IS COUNTED FROM THE QUEUE, NOT FROM THE WORKER. A client drains
+// its receipts with four workers, so a fifth owed receipt waits behind four slow
+// ones before any worker asks for it; its whole schedule used to start there,
+// and it was answered about eighty seconds after it was queued — past the
+// seventy a run's books wait for it ([ReceiptWait]), so the run closed without
+// that call's price. A receipt now carries the instant it was queued, and its
+// ceiling is that instant plus the schedule's own.
+func TestAReceiptIsAnsweredWithinReceiptWaitOfBeingQueued(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // The generation is never priced, so only the ceiling can end the schedule.
+ fmt.Fprint(w, `{"data":{}}`)
+ }))
+ t.Cleanup(server.Close)
+ client := receiptTestClient(t, server)
+
+ // The queue stamps each receipt as it is admitted. No worker is started
+ // here, so the queued work can be read back as it was admitted.
+ client.receiptRunning = receiptWorkerCount
+ before := time.Now()
+ client.settle(WithReconcile(t.Context(), func(Reconciled) {}), "sim/model", &ai.Response{ID: "gen-queued"}, "torn", 0)
+ after := time.Now()
+ queued := <-client.receipts
+ if queued.queued.Before(before) || queued.queued.After(after) {
+ t.Fatalf("the receipt was stamped %v, want the instant it was queued (%v to %v)", queued.queued, before, after)
+ }
+
+ // A receipt that has already waited in the queue for nearly the whole bound
+ // is given only what is left of it, however long its pauses would run.
+ client.wait = func(ctx context.Context, _ time.Duration) error {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ results := make(chan Reconciled, 1)
+ late := receiptWork{
+ result: Reconciled{Ref: "gen-late"},
+ sink: func(result Reconciled) { results <- result },
+ queued: time.Now().Add(-(ReceiptWait - 300*time.Millisecond)),
+ }
+ go client.reconcile(late)
+ if result := receiptResult(t, results); result.Found {
+ t.Fatalf("a receipt the provider never priced was found: %+v", result)
+ }
+ if answered := time.Since(late.queued); answered > ReceiptWait+2*time.Second {
+ t.Fatalf("the receipt was answered %v after it was queued, past ReceiptWait %v", answered, ReceiptWait)
+ }
+}
diff --git a/internal/remote/client.go b/internal/remote/client.go
index 1becfd22fc..4d6b39b984 100644
--- a/internal/remote/client.go
+++ b/internal/remote/client.go
@@ -1559,6 +1559,37 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64,
return started.ID, started.Title, started.Note, nil
}
+// Delegates is the programs the engine machine's build carries, as the surface
+// draws its rows from them (internal/session's delegate_door.go). A failed read
+// is the zero report — no rows — because a list is a reading and never worth a
+// refusal at the door.
+func (a *Agent) Delegates() session.DelegateReport {
+ payload, err := a.c.call(context.Background(), MethodDelegateList, nil)
+ if err != nil {
+ return session.DelegateReport{}
+ }
+ var report session.DelegateReport
+ if err := json.Unmarshal(payload, &report); err != nil {
+ return session.DelegateReport{}
+ }
+ return report
+}
+
+// StartDelegate hands the brief to the named program on the engine machine
+// and returns the same receipt StartTask does. It is an ordinary call with the
+// ordinary deadline: the engine admits the run at once.
+func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, string, string, error) {
+ payload, err := a.c.call(ctx, MethodDelegateStart, DelegateStartArgs{Name: name, Brief: brief})
+ if err != nil {
+ return 0, "", "", err
+ }
+ var started TaskStarted
+ if err := json.Unmarshal(payload, &started); err != nil {
+ return 0, "", "", err
+ }
+ return started.ID, started.Title, started.Note, nil
+}
+
// StartPlannerRun opens the adaptive form on the engine machine.
func (a *Agent) StartPlannerRun(ctx context.Context, brief, hint string) (string, string, error) {
payload, err := a.c.call(ctx, MethodPlannerStart, PlannerStartArgs{Brief: brief, Hint: hint})
@@ -1970,15 +2001,30 @@ func (a *Agent) PlanTasks() []session.PlanTaskRow {
// PlanTaskPage reads one complete task page from the engine.
func (a *Agent) PlanTaskPage(id string) (session.PlanTaskPage, bool) {
- payload, err := a.c.call(nil, MethodPlanTaskPage, PlanTaskPageArgs{ID: id})
+ page, found, err := a.ReadPlanTaskPage(id)
if err != nil {
return session.PlanTaskPage{}, false
}
+ return page, found
+}
+
+// ReadPlanTaskPage is [Agent.PlanTaskPage] with the engine's refusal kept.
+//
+// A READING WINDOW NEEDS THE REFUSAL. A page opened onto another
+// conversation's program task reads nothing but this, and the one way it
+// learns the conversation under it was replaced is the engine's own sentence
+// ([ErrJoinedGone]) — which the plan capability's (page, found) shape has
+// nowhere to put (internal/tui3's [tui3.TaskOwnerView.TaskPage]).
+func (a *Agent) ReadPlanTaskPage(id string) (session.PlanTaskPage, bool, error) {
+ payload, err := a.c.call(nil, MethodPlanTaskPage, PlanTaskPageArgs{ID: id})
+ if err != nil {
+ return session.PlanTaskPage{}, false, err
+ }
var result PlanTaskPageResult
- if json.Unmarshal(payload, &result) != nil {
- return session.PlanTaskPage{}, false
+ if err := json.Unmarshal(payload, &result); err != nil {
+ return session.PlanTaskPage{}, false, err
}
- return result.Page, result.OK
+ return result.Page, result.OK, nil
}
// PlanTaskWork reads the run's working copy over the wire. An engine that
diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go
index b654869218..878aa464c1 100644
--- a/internal/remote/client_test.go
+++ b/internal/remote/client_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/plandb"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/standing"
@@ -1000,6 +1001,8 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) {
Depth: 2, Waits: []string{"t-a", "t-b"}, Steps: 7, USD: 1.25,
Started: started, Ended: ended, Note: "last note",
Live: plandb.LiveStep{Step: 8, Command: "go test ./internal/remote", Since: started},
+ Program: "senior-dev",
+ Stage: "implement",
TrajectoryPath: "/tmp/trajectory.jsonl",
}
page := session.PlanTaskPage{
@@ -1009,6 +1012,29 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) {
Live: row.Live,
Children: []session.PlanTaskRow{row},
WaitRows: []session.PlanTaskRow{row},
+ // A PROGRAM'S CONVERSATION CROSSES WITH ITS PAGE, on the page's own call
+ // and in no call of its own: an answered turn with everything a turn can
+ // carry, a refused one, and the one still in flight — and so do its
+ // actions, each with everything an action can carry.
+ Program: &session.PlanProgram{
+ Name: "senior-dev", Stages: []string{"intake", "implement"},
+ Turns: []delegate.Turn{
+ {Seq: 1, Thread: "main", Started: started, Ended: ended, Model: "deepseek/deepseek-v4-flash", Served: "deepseek/deepseek-v4-flash-0731",
+ Sent: []delegate.Said{{Role: "user", Text: "rewrite the wire"}, {Role: "tool", Tool: "read", Text: "package remote"}},
+ Reply: "I'll read the wire first.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"wire.go"}`}},
+ TokensIn: 1200, TokensOut: 40, Cached: 800, CostUSD: 0.012},
+ {Seq: 2, Thread: "main", Started: started, Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"},
+ {Seq: 3, Thread: "main", Started: ended, Model: "deepseek/deepseek-v4-flash", Restarted: true},
+ },
+ Earlier: 4, Calls: 6, CeilingUSD: 5,
+ Actions: []delegate.Shown{
+ {At: started, Step: "explore", Text: "ran go test ./internal/remote", Outcome: "fails · exit 1"},
+ {At: started.Add(time.Second), Text: "compacted its memory", Outcome: "kept its own record", Memory: true},
+ {At: ended, Text: "switched to deepseek-v4-flash", Model: "openrouter/deepseek/deepseek-v4-flash", Reason: "the last one was busy"},
+ {At: ended, Step: "implement", Text: "told its model to finish (nudge 1)", Steer: true},
+ },
+ EarlierActions: 12,
+ },
}
e.answers[MethodPlanTasks] = []session.PlanTaskRow{row}
e.answers[MethodPlanTaskPage] = PlanTaskPageResult{Page: page, OK: true}
@@ -1029,6 +1055,21 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) {
}
}
+// A READING WINDOW'S PAGE READ KEEPS THE ENGINE'S REFUSAL. The plan
+// capability folds every failure into "not found"; a page onto another
+// conversation's program task reads nothing else, and the refusal is how it
+// learns that conversation was replaced.
+func TestReadPlanTaskPageKeepsTheEnginesRefusal(t *testing.T) {
+ client, e := newEngine(t)
+ e.fails[MethodPlanTaskPage] = "engine: that conversation is not open here any more"
+ if _, found, err := client.Agent().ReadPlanTaskPage("7"); found || err == nil || !strings.Contains(err.Error(), "not open here any more") {
+ t.Fatalf("ReadPlanTaskPage = (found %v, %v), want the engine's own refusal", found, err)
+ }
+ if _, found := client.Agent().PlanTaskPage("7"); found {
+ t.Fatal("PlanTaskPage found a page the engine refused")
+ }
+}
+
func TestRunSummariesCrossWholeAndDroppedRefreshKeepsNothing(t *testing.T) {
client, e := newEngine(t)
want := session.RunPlanSummary{
diff --git a/internal/remote/driver.go b/internal/remote/driver.go
index c19a58ff91..0eadada68d 100644
--- a/internal/remote/driver.go
+++ b/internal/remote/driver.go
@@ -280,10 +280,19 @@ const watchingWord = "this window is reading this conversation, not typing into
// change (tasklane.go) — and it is what a reading page asks INSTEAD of guessing
// from files on the machine it happens to be running on
// (internal/tui3's taskowner.go).
+//
+// AND ONE TASK'S STORED PAGE IS ON IT BECAUSE A PROGRAM'S TASK HAS NO JOURNAL.
+// senior-dev writes no worker transcript for [MethodTaskRoom] to read; what it
+// did is its conversation with codeaf on the task's page in the plan store, so
+// a reading page onto a program's task reads [MethodPlanTaskPage] instead
+// (internal/tui3's [app.guestPageRead]). It opens the store's read handles and
+// changes nothing. Every verb on that page — a note, a pause, a stop — is NOT
+// here and must not be: they act on the work, which is the owner's.
var watcherReads = map[string]bool{
MethodTaskRoom: true,
MethodTaskWatch: true,
MethodQuestionWatch: true,
+ MethodPlanTaskPage: true,
MethodDetach: true,
MethodPing: true,
}
diff --git a/internal/remote/driver_test.go b/internal/remote/driver_test.go
index 64477f9abd..a650600529 100644
--- a/internal/remote/driver_test.go
+++ b/internal/remote/driver_test.go
@@ -446,6 +446,55 @@ func TestAWatchingSurfaceIsRefusedEveryDoorThatChangesAnything(t *testing.T) {
}
}
+// A READING SURFACE MAY READ ONE TASK'S STORED PAGE AND NONE OF ITS VERBS. A
+// program's task writes no worker journal, so the page another window opens
+// onto it reads the task's page in the owner's store instead
+// (internal/tui3's taskowner.go). The page's verbs — a note, a pause, a stop —
+// act on the owner's work and stay refused. And the read is bound to the
+// conversation the reader joined, as the journal is: once the owner opens
+// something else, it is told rather than handed the replacement's task.
+func TestAReadingSurfaceReadsAProgramsPageAndNoneOfItsVerbs(t *testing.T) {
+ first := &fakeAgent{model: "a/b", title: "the one being read"}
+ second := &fakeAgent{model: "a/b", title: "something else"}
+ engine := engineOn(first)
+ engine.Fresh = func() (WrappedAgent, string, error) { return second, "/sessions/two.jsonl", nil }
+ sess := NewSession(engine, true)
+
+ owner := dialSession(t, sess)
+ owner.hello(Hello{Version: Version, Surface: "macbook"})
+ reader := dialSession(t, sess)
+ reader.hello(Hello{
+ Version: Version, Surface: "reader",
+ Session: engine.SessionFile, Join: true, Watch: true,
+ })
+
+ if frame := reader.call(1, MethodPlanTaskPage, PlanTaskPageArgs{ID: "7"}); frame.Error != "" {
+ t.Fatalf("the reader was refused a program's page: %v", frame.Error)
+ }
+ for id, call := range []struct {
+ method string
+ payload any
+ }{
+ {MethodPlanNote, PlanTextArgs{ID: "7", Text: "go faster"}},
+ {MethodPlanPause, PlanTaskArgs{ID: "7"}},
+ {MethodPlanCancel, PlanTaskArgs{ID: "7"}},
+ } {
+ frame := reader.call(uint64(id+10), call.method, call.payload)
+ if !strings.Contains(frame.Error, watchingWord) {
+ t.Fatalf("%s on a reading surface answered %q, want the reader's own refusal", call.method, frame.Error)
+ }
+ }
+ if len(first.planSteers) != 0 {
+ t.Fatalf("a reading surface acted on the owner's work: %v", first.planSteers)
+ }
+
+ owner.ok(20, MethodSessionNew, nil)
+ frame := reader.call(21, MethodPlanTaskPage, PlanTaskPageArgs{ID: "7"})
+ if !strings.Contains(frame.Error, "not open here any more") {
+ t.Fatalf("after the owner opened something else the reader's page read answered %q, want the sentence its page acts on", frame.Error)
+ }
+}
+
// ── the client half ─────────────────────────────────────────────────────────
// The real client against the real engine over an in-memory pipe: what a
diff --git a/internal/remote/places_test.go b/internal/remote/places_test.go
index 5703d7a8bc..f7acbb3804 100644
--- a/internal/remote/places_test.go
+++ b/internal/remote/places_test.go
@@ -43,6 +43,7 @@ func farWorld(now time.Time) session.World {
ID: "1", Name: "trimming", Label: "trimming the index",
Title: "trimming the index", Status: string(session.TaskDone),
Cost: 22.54, SessionID: "bbbb000000000002", EndedAt: now,
+ Program: "senior-dev",
}}},
}},
}},
@@ -130,6 +131,11 @@ func TestTheWorldCrossesTheWire(t *testing.T) {
if len(tasks) != 1 || tasks[0].Label != "trimming the index" || tasks[0].Cost != 22.54 {
t.Fatalf("the work did not cross: %+v", tasks)
}
+ // AND WHICH PROGRAM HAD IT, which is the badge the far machine's tasks place
+ // draws on the row.
+ if tasks[0].Program != "senior-dev" {
+ t.Fatalf("the work's program did not cross: %+v", tasks[0])
+ }
}
// An engine with no world door REFUSES, and the refusal is not an empty world.
diff --git a/internal/remote/server.go b/internal/remote/server.go
index 09b9815fd4..59c5189609 100644
--- a/internal/remote/server.go
+++ b/internal/remote/server.go
@@ -2316,6 +2316,28 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) {
return nil, err
}
return json.Marshal(TaskStarted{ID: id, Title: title, Note: note})
+ case MethodDelegateList:
+ door, ok := agent.(interface{ Delegates() session.DelegateReport })
+ if !ok {
+ return json.Marshal(session.DelegateReport{})
+ }
+ return json.Marshal(door.Delegates())
+ case MethodDelegateStart:
+ door, ok := agent.(interface {
+ StartDelegate(context.Context, string, string) (uint64, string, string, error)
+ })
+ if !ok {
+ return nil, errors.New("engine: this session has no delegate door")
+ }
+ args, err := arg[DelegateStartArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ id, title, note, err := door.StartDelegate(context.Background(), args.Name, args.Brief)
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(TaskStarted{ID: id, Title: title, Note: note})
case MethodPlannerStart:
door, ok := agent.(interface {
StartPlannerRun(context.Context, string, string) (string, string, error)
diff --git a/internal/remote/tasklane_test.go b/internal/remote/tasklane_test.go
index ff36e9d4af..116da015d7 100644
--- a/internal/remote/tasklane_test.go
+++ b/internal/remote/tasklane_test.go
@@ -158,6 +158,32 @@ func TestAHandStartedTaskReachesTheHostedRail(t *testing.T) {
}
}
+// A PROGRAM'S ROW KEEPS ITS PROGRAM OVER THE WIRE, so a surface on the near side
+// draws the badge a program's work wears (internal/tui3's programbadge.go) from
+// the row's first frame, exactly as a window on the far machine would.
+func TestAProgramsRowCrossesTheWireNamingItsProgram(t *testing.T) {
+ far := &railAgent{fakeAgent: &fakeAgent{}}
+ loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) {
+ return &Engine{Agent: far, Workspace: "/srv/app", SessionFile: "/srv/app/j.jsonl"}, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = loop.Close() })
+
+ lane, stop := loop.Client.Agent().WatchTaskUpdates()
+ t.Cleanup(stop)
+ waitFor(t, "the engine opened the surface's task lane", func() bool { return far.opened() == 1 })
+
+ row := taskEvent(9, "rewrite the auth middleware", session.TaskRunning)
+ row.Task.Program = "senior-dev"
+ far.land(row)
+ event := nextTask(t, lane)
+ if event.Task == nil || event.Task.ID != 9 || event.Task.Program != "senior-dev" {
+ t.Fatalf("the program's row crossed as %+v, want it naming senior-dev", event.Task)
+ }
+}
+
// A FIRING USES THE REAL SESSION LANE OVER THE REAL WIRE. The scripted rail
// above proves that a task-shaped event can cross; this is the other producer
// of that lane, whose event has no task payload and is raised outside a turn.
diff --git a/internal/remote/wire.go b/internal/remote/wire.go
index b49bdc2b4a..8d82fae99f 100644
--- a/internal/remote/wire.go
+++ b/internal/remote/wire.go
@@ -344,7 +344,16 @@ import (
// ReplaceQuestion. It also carries whether a caller has no approval resolver.
// Older peers must refuse before a question or an unwatched tool can run under
// semantics the other side does not understand.
-const Version = 17
+//
+// VERSION 18 CARRIES THE DELEGATE DOOR — [MethodDelegateList] and
+// [MethodDelegateStart] (wire_task.go). The number moves for [MethodTaskStart]'s
+// reason: `Delegate.Start` COMMISSIONS WORK on the far machine and spends its
+// money, so a version-17 engine answering "no such method" would leave a person
+// told their work was under way while nothing had started. The list rides the
+// same number because a surface generates its command rows from it before its
+// first frame, and a row for a program the engine cannot start is a command
+// that lies.
+const Version = 18
// AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods
// rode version 5's: neither half can be surprised by them. "phase" and "lane"
diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go
index 276b6e547f..38c9a21e03 100644
--- a/internal/remote/wire_task.go
+++ b/internal/remote/wire_task.go
@@ -6,6 +6,14 @@ import "time"
// machine. The surface sends intent; sizing, shaping, admission and spending
// remain with the session agent that owns the conversation.
const (
+ // MethodDelegateList and MethodDelegateStart are the program door
+ // (internal/session's delegate_door.go): the programs the ENGINE machine's
+ // build carries, and handing a brief to one. They belong to the engine side
+ // for the reason the task door does — the program runs on that machine and
+ // the run spends that machine's money — so a hosted surface lists the far
+ // build's programs and its `/ ` starts work there.
+ MethodDelegateList = "Delegate.List"
+ MethodDelegateStart = "Delegate.Start"
MethodTaskStart = "Task.Start"
MethodPlannerStart = "Task.StartPlanner"
MethodTaskRoom = "Task.Room"
@@ -157,6 +165,13 @@ type TaskStartArgs struct {
Solo bool `json:"solo,omitempty"`
}
+// DelegateStartArgs carries the program's name and the person's brief, both
+// as typed: the name is resolved against the engine machine's build there.
+type DelegateStartArgs struct {
+ Name string `json:"name"`
+ Brief string `json:"brief"`
+}
+
// PlannerStartArgs also carries the sizing hint used by the adaptive form.
type PlannerStartArgs struct {
Brief string `json:"brief"`
diff --git a/internal/run/crew.go b/internal/run/crew.go
index be4cd7ac6e..0936c5a1d6 100644
--- a/internal/run/crew.go
+++ b/internal/run/crew.go
@@ -114,32 +114,49 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats,
// role, so RoleOf's error needs no reader here.
role, _ := store.RoleOf(task.ID)
tier := SeatFor(role)
- // THE DOOR'S SEAT WINS WHERE IT NAMED ONE. A planner (the run's root or
- // a task that has children) rides the plan seat. A check rides the careful
- // work seat. A leaf and every task an unknown role falls to the work seat. The probe tier is named by nobody, so it
- // keeps the profile's row below.
- var model string
- switch tier {
- case config.ModelTierMastermind:
- model = seats.Plan
- case config.ModelTierWorker:
- model = seats.Work
- case config.ModelTierHigh:
- model = seats.Check
- }
- if model == "" {
- model = config.TierSeatAt(profileDir, tier).Model
- }
+ model := seatModel(profileDir, tier, seats)
if model == "" {
- model = config.TierSeatAt(profileDir, config.ModelTierWorker).Model
- if model == "" {
- return seatlessWorker{tier: tier}
- }
+ return seatlessWorker{tier: tier}
}
return NewBashWorker(store, workspace, model, completerFor(model))
}
}
+// seatModel is the model a task riding tier is seated on, and the one answer
+// both the crew's workers and a delegated program's model API read.
+//
+// THE DOOR'S SEAT WINS WHERE IT NAMED ONE. A planner (the run's root or a task
+// that has children) rides the plan seat. A check rides the careful work seat.
+// A leaf and every task an unknown role falls to the work seat. The probe tier
+// is named by nobody, so it keeps the profile's row. A tier with no model
+// falls to the worker row, and empty is a seat no model can fill.
+func seatModel(profileDir, tier string, seats Seats) string {
+ var model string
+ switch tier {
+ case config.ModelTierMastermind:
+ model = seats.Plan
+ case config.ModelTierWorker:
+ model = seats.Work
+ case config.ModelTierHigh:
+ model = seats.Check
+ }
+ if model == "" {
+ model = config.TierSeatAt(profileDir, tier).Model
+ }
+ if model == "" {
+ model = config.TierSeatAt(profileDir, config.ModelTierWorker).Model
+ }
+ return model
+}
+
+// WorkSeat is the model this run's own work seat holds: the door's work seat
+// where it named one, the profile's worker row otherwise — exactly the seat a
+// leaf of the run is built on ([CrewFactory]). A delegated program's model API
+// answers on it whatever the program asks for that nothing here can reach.
+func WorkSeat(profileDir, work string) string {
+ return seatModel(profileDir, config.ModelTierWorker, Seats{Work: work})
+}
+
// seatlessWorker is the seat a task gets when the crew holds no model for its
// tier and none on the worker row either. It runs nothing and reports an error,
// because a task that cannot be seated must fail with the row that has to be
diff --git a/internal/run/cut_root_test.go b/internal/run/cut_root_test.go
new file mode 100644
index 0000000000..4be974f6f1
--- /dev/null
+++ b/internal/run/cut_root_test.go
@@ -0,0 +1,38 @@
+package run
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/plandb"
+)
+
+// A RUN THE CALLER CUT IS LEFT OPEN, WHICHEVER WAY THE SELECT FELL. When the
+// caller's context ends, the root worker comes home with the context's own
+// error, and that return and the context's end are ready at the loop's select
+// together. The select took the return first here, as Go may: the pass that
+// follows must answer incomplete and leave the root as the run left it, the
+// same ending the caller's wall gives, and never write `context canceled` over
+// the run's task as though its work had failed.
+func TestARootTheCallerCutIsNotFailedInItsStore(t *testing.T) {
+ for _, cut := range []error{context.Canceled, fmt.Errorf("the program stopped: %w", context.DeadlineExceeded)} {
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "cut", "root", "The run", "cut")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := NewSupervisor(store, t.TempDir(), 1, Limits{}, nil)
+ s.dispatchedRoot = true
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ s.absorb(workerReturn{task: *store.Task("root"), err: cut})
+ if got := s.pass(ctx, "root"); got != OutcomeIncomplete {
+ t.Fatalf("a cut root's pass answered %q, want %q", got, OutcomeIncomplete)
+ }
+ if root := store.Task("root"); root.Status == plandb.StatusFailed || root.Error != "" {
+ t.Fatalf("the caller's cut was written as the run failing: %s (%q)", root.Status, root.Error)
+ }
+ _ = store.Close()
+ }
+}
diff --git a/internal/run/delegate_child_test.go b/internal/run/delegate_child_test.go
new file mode 100644
index 0000000000..ba06b8154b
--- /dev/null
+++ b/internal/run/delegate_child_test.go
@@ -0,0 +1,155 @@
+package run_test
+
+// The program a delegated run's REAL child runs: this test binary, started by
+// the worker exactly as it starts codeaf's own executable — the program's line
+// after it, the model API's address and token in its environment and no key —
+// and marked by [delegateChildEnv] so its TestMain runs the program instead of
+// the suite. It is how the worker is tested against a process that is really
+// another process, speaking the records on a real pipe and calling the real
+// model API over a real socket, rather than against a script that only
+// pretends to.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+)
+
+// delegateChildEnv marks a process started as a delegate's child.
+const delegateChildEnv = "RUN_TEST_DELEGATE_CHILD"
+
+// childProgram is the fake program: it says hello, asks the model API
+// FAKE_CALLS questions — a step for each answer — and ends passing, or, with
+// FAKE_ENDING=wait, waits to be told to stop and says it stopped. Its terminal
+// claims a cost of its own that no bank may believe.
+func childProgram() delegate.Delegate {
+ return delegate.Delegate{
+ Name: "fake", Summary: "a fake program", Default: "run", Page: "fake",
+ Commands: []delegate.Command{{
+ Name: "run", Usage: "[flags] -- ", Summary: "does the whole task",
+ Bind: func(*flag.FlagSet) delegate.Body { return childBody },
+ }},
+ }
+}
+
+func childBody(ctx context.Context, host delegate.Host, args []string) error {
+ if path := os.Getenv("FAKE_API_FILE"); path != "" {
+ api := host.Models()
+ _ = os.WriteFile(path, []byte(api.BaseURL+"\n"+api.Token+"\n"), 0o600)
+ }
+ if path := os.Getenv("FAKE_ENV"); path != "" {
+ _ = os.WriteFile(path, []byte(strings.Join(os.Environ(), "\n")), 0o600)
+ }
+ host.Hello([]string{"implement", "verify"})
+ host.Stage(delegate.StageRecord{Stage: "implement", Status: "running"})
+ calls, _ := strconv.Atoi(os.Getenv("FAKE_CALLS"))
+ for call := 1; call <= calls; call++ {
+ if ctx.Err() != nil {
+ break
+ }
+ reply, err := askModel(ctx, host.Models(), fmt.Sprintf("call %d: %s", call, strings.Join(args, " ")))
+ if err != nil {
+ host.Step(delegate.StepRecord{Command: "model: ask", Observation: "refused: " + err.Error()})
+ if os.Getenv("FAKE_ENDING") == "crash" {
+ // senior-dev's own ending after a refusal: its sum of its
+ // answers' costs never reached its ceiling, so it cannot tell a
+ // ceiling from a broken road and says it crashed.
+ host.Terminal(delegate.Ending{Status: delegate.StatusCrashed, Message: "the model road refused a call"})
+ return nil
+ }
+ continue
+ }
+ host.Step(delegate.StepRecord{Command: "model: ask", Observation: reply})
+ }
+ if os.Getenv("FAKE_ENDING") == "wait" || ctx.Err() != nil {
+ <-ctx.Done()
+ host.Terminal(delegate.Ending{Status: delegate.StatusBudget, Message: "told to stop", CostUSD: 99})
+ return nil
+ }
+ host.Stage(delegate.StageRecord{Stage: "verify", Status: "pass"})
+ host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "submitted and verified", Claim: "all green", Observed: "pass", CostUSD: 99})
+ return nil
+}
+
+// askModel is one call through the model API, the way any OpenAI client
+// makes one: the route joined to the base, the bearer token, one question,
+// the answer's words back — or the API's own refusal as the error.
+func askModel(ctx context.Context, api delegate.ModelAPI, question string) (string, error) {
+ body, _ := json.Marshal(map[string]any{
+ "model": "deepseek/deepseek-v4-flash-0731",
+ "messages": []map[string]string{{"role": "system", "content": "be brief"}, {"role": "user", "content": question}},
+ })
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, modelapi.ChatURL(api.BaseURL), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ api.Authorize(request)
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ return "", err
+ }
+ defer response.Body.Close()
+ payload, err := io.ReadAll(response.Body)
+ if err != nil {
+ return "", err
+ }
+ var answer struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil {
+ return "", fmt.Errorf("%d: %s", response.StatusCode, payload)
+ }
+ if answer.Error != nil {
+ return "", fmt.Errorf("%d: %s", answer.Error.Code, answer.Error.Message)
+ }
+ if len(answer.Choices) == 0 {
+ return "", errors.New("no choices")
+ }
+ return answer.Choices[0].Message.Content, nil
+}
+
+// runAsDelegateChild runs the fake program when this binary was started as a
+// delegate's child, and says whether it was.
+func runAsDelegateChild() (int, bool) {
+ if os.Getenv(delegateChildEnv) != "1" {
+ return 0, false
+ }
+ program := childProgram()
+ if len(os.Args) < 2 || os.Args[1] != program.Name {
+ fmt.Fprintf(os.Stderr, "started as a delegate's child with %q\n", os.Args)
+ return 3, true
+ }
+ inv, err := delegate.Parse(program, os.Args[2:], os.Stdout)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ return 1, true
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ if delegate.RunChild(ctx, inv, os.Stdout) == delegate.StatusPass {
+ return 0, true
+ }
+ return 2, true
+}
diff --git a/internal/run/delegate_money_test.go b/internal/run/delegate_money_test.go
new file mode 100644
index 0000000000..be5b9179e8
--- /dev/null
+++ b/internal/run/delegate_money_test.go
@@ -0,0 +1,228 @@
+//go:build !windows
+
+package run_test
+
+import (
+ "context"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/run"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// A PROGRAM'S CALLS ARE THE CONVERSATION'S CHILDREN, EACH ONCE. Every ledger
+// row a delegated run writes names the conversation as its Root and its
+// Session and the task as its Task, so the conversation's receipt counts the
+// calls under the work it started — never under its own calls, never twice —
+// and the spending page files them under the task. Every call also reaches
+// the conversation's fold whole, tokens and model with its dollars.
+func TestADelegatedRunsLedgerRowsNameTheConversationAndTheTask(t *testing.T) {
+ store := runOpenStore(t)
+ program, setup, _, ledger := realChild(t, 0.05, "2")
+ const conversation = "3d6ddfd172b2960f"
+ var mu sync.Mutex
+ var charges []session.RunCharge
+ setup.Conversation = conversation
+ setup.OnCharge = func(charge session.RunCharge) {
+ mu.Lock()
+ defer mu.Unlock()
+ charges = append(charges, charge)
+ }
+ worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0)
+ if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil {
+ t.Fatal(err)
+ }
+ rows := ledgerRows(t, ledger)
+ if len(rows) != 2 {
+ t.Fatalf("ledger rows = %+v", rows)
+ }
+ for _, row := range rows {
+ if row.Root != conversation || row.Session != conversation || row.Task != store.RootID() {
+ t.Fatalf("ledger row = %+v, want the conversation as Root and Session and the task as Task", row)
+ }
+ }
+ receipt := session.UsageTree(rows, conversation)
+ if receipt.Children != 0.1 || receipt.Direct != 0 || receipt.Calls != 2 {
+ t.Fatalf("the conversation's receipt = %+v, want the two calls once each, under the work it started", receipt)
+ }
+ subjects := session.UsageBySubject(rows)
+ if len(subjects) != 1 || subjects[0].Kind != session.SubjectTask || subjects[0].ID != store.RootID() ||
+ subjects[0].Root != conversation || subjects[0].USD != 0.1 || subjects[0].Calls != 2 {
+ t.Fatalf("spend by subject = %+v, want one task row holding both calls", subjects)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(charges) != 2 || charges[0].USD != 0.05 || charges[0].TokensIn != 100 || charges[0].Cached != 60 ||
+ charges[0].Model != "deepseek/deepseek-v4-flash-0731" {
+ t.Fatalf("folded charges = %+v, want each call whole", charges)
+ }
+}
+
+// owingFunnel answers every call at once without a usage block and owes its
+// receipt, which it delivers after a delay — the provider's own order, owed
+// before the fetch and answered after the sink has the money.
+type owingFunnel struct {
+ late time.Duration
+ cost float64
+}
+
+func (f *owingFunnel) completerFor(string) session.Completer { return f }
+
+func (f *owingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ done := provider.ReceiptPendingFrom(ctx)()
+ sink := provider.ReconcileSinkFrom(ctx)
+ go func() {
+ defer done()
+ time.Sleep(f.late)
+ sink(provider.Reconciled{Billed: provider.Billed{Model: request.Model, PromptTokens: 52139, CompletionTokens: 4895, Cost: f.cost}, Found: true})
+ }()
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "cut short"}}},
+ FinishReason: "stop",
+ }}}, nil
+}
+
+// THE CALL A RUN WAS CUT IN THE MIDDLE OF IS IN THE RUN'S BOOKS. Its price
+// arrives by receipt after the program has exited — twenty seconds late on the
+// stopped runs of 2026-09-23 — and the worker waits for it before it reports,
+// so the run's total, the task's spend rows and the conversation's fold all
+// hold it while the store is still open. Before the wait, the report read $0
+// and the store had no row.
+func TestDelegateWorkerBanksTheReceiptThatArrivesAfterTheProgramExited(t *testing.T) {
+ store := runOpenStore(t)
+ program, setup, _, ledger := realChild(t, 0, "1")
+ owing := &owingFunnel{late: 400 * time.Millisecond, cost: 0.058188488}
+ setup.CompleterFor = owing.completerFor
+ var mu sync.Mutex
+ var folded float64
+ var liveWhilePriced int
+ setup.OnCharge = func(charge session.RunCharge) {
+ mu.Lock()
+ defer mu.Unlock()
+ folded += charge.USD
+ // The receipt lands while the API's close waits for it: the program is
+ // gone, so its live step must be too.
+ liveWhilePriced = len(store.LiveSteps())
+ }
+ worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0)
+ report, err := worker.Run(runContext(t), *store.Task(store.RootID()))
+ if err != nil {
+ stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log"))
+ t.Fatalf("run: %v\n%s", err, stderr)
+ }
+ if report.USD != 0.058188488 {
+ t.Fatalf("report usd = %v, want the late receipt's $0.058188488", report.USD)
+ }
+ if spend := store.SpendSummary().ByModel["delegate/fake"]; spend.USD != 0.058188488 || spend.Calls != 1 {
+ t.Fatalf("spend rows = %+v, want the late receipt's row", store.SpendSummary().ByModel)
+ }
+ mu.Lock()
+ if folded != 0.058188488 || liveWhilePriced != 0 {
+ mu.Unlock()
+ t.Fatalf("folded %v with %d live steps while the receipt was owed, want the late receipt and none", folded, liveWhilePriced)
+ }
+ mu.Unlock()
+ if rows := ledgerRows(t, ledger); len(rows) != 1 || !rows[0].Reconciled || rows[0].USD != 0.058188488 {
+ t.Fatalf("ledger rows = %+v", rows)
+ }
+}
+
+// A SPEND ROW THE STORE REFUSES IS SAID, NOT DROPPED. A receipt so late it
+// outlived the worker's wait reaches a store that has closed; the ledger has
+// it, and the task's record folder says the task's rows do not.
+func TestAChargeTheStoreRefusedIsWrittenDownInTheTasksRecord(t *testing.T) {
+ store := runOpenStore(t)
+ taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())
+ program, setup, _, ledger := realChild(t, 0.05, "1")
+ setup.CompleterFor = (&closingFunnel{store: store}).completerFor
+ worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0)
+ if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil {
+ t.Fatal(err)
+ }
+ stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log"))
+ if !strings.Contains(string(stderr), "codeaf: a charge of $0.050000 for a call on deepseek/deepseek-v4-flash-0731 is not in this task's spend rows") {
+ t.Fatalf("delegate-stderr.log:\n%s", stderr)
+ }
+ if rows := ledgerRows(t, ledger); len(rows) != 1 || rows[0].USD != 0.05 {
+ t.Fatalf("ledger rows = %+v", rows)
+ }
+}
+
+// closingFunnel closes the run's store before it bills its one call, the way
+// a store has closed under a receipt that arrived after the run was over.
+type closingFunnel struct{ store *plandb.Store }
+
+func (f *closingFunnel) completerFor(string) session.Completer { return f }
+
+func (f *closingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ _ = f.store.Close()
+ if sink := provider.BillingSinkFrom(ctx); sink != nil {
+ sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, Cost: 0.05})
+ }
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "ok"}}},
+ FinishReason: "stop",
+ }}}, nil
+}
+
+// A RUN HANDED NOTHING OF ITS LIMIT SPENDS NOTHING AND ENDS ON THE LIMIT. The
+// conversation hands a run whose person's limit is already spent the smallest
+// positive figure; the program's first call is refused before it is made, and
+// the run ends on the person's cost limit rather than as work that broke.
+func TestARunWhoseLimitIsAlreadySpentMakesNoCallAndEndsOnTheLimit(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup, calling, _ := realChild(t, 0.139463, "3")
+ t.Setenv("FAKE_ENDING", "crash")
+ spent := run.Limits{CostUSD: math.SmallestNonzeroFloat64}
+ factory := run.DelegateFactory(store, t.TempDir(), m, setup, spent, nil)
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store, Workspace: t.TempDir(), Slots: 1, Limits: spent, Factory: factory,
+ })
+ if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost {
+ t.Fatalf("outcome %q limit %q, want the cost limit", outcome, summary.Limit)
+ }
+ if summary.USD != 0 || len(calling.seen()) != 0 {
+ t.Fatalf("usd %v after %d funnel calls, want nothing made and nothing spent", summary.USD, len(calling.seen()))
+ }
+}
+
+// AND NO WORKER OF ANY KIND IS SEATED on a run handed nothing of its limit: a
+// worker that meters only its own total would otherwise make the one paid call
+// that tells the loop the limit is gone.
+func TestARunWhoseLimitIsAlreadySpentSeatsNoWorker(t *testing.T) {
+ store := runOpenStore(t)
+ var seated int
+ var mu sync.Mutex
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store, Workspace: t.TempDir(), Slots: 1,
+ Limits: run.Limits{CostUSD: math.SmallestNonzeroFloat64},
+ Factory: func(plandb.Task) run.Worker {
+ mu.Lock()
+ seated++
+ mu.Unlock()
+ return nil
+ },
+ })
+ mu.Lock()
+ defer mu.Unlock()
+ if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost || seated != 0 {
+ t.Fatalf("outcome %q limit %q with %d workers seated, want the cost limit and none", outcome, summary.Limit, seated)
+ }
+}
diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go
new file mode 100644
index 0000000000..1a1144d0d3
--- /dev/null
+++ b/internal/run/delegateworker.go
@@ -0,0 +1,655 @@
+package run
+
+// A PROGRAM CODEAF CARRIES IS ONE MORE WORKER KIND. A program that does a
+// whole task on its own — senior-dev first (internal/delegate,
+// docs/design/delegate/PROTOCOL.md) — is seated on a task exactly where the
+// bash worker is: it reads the same context for its limits, banks its dollars
+// into the same account, publishes the same live step, appends to the same
+// trajectory, and comes home with the same Report. Nothing above the factory
+// knows which kind ran.
+//
+// What differs is inside: there is no model turn here. The program runs as a
+// child process of codeaf's own executable (`codeaf run --json …`) in
+// the run's folder (for a program that edits files, the person's folder itself,
+// readied by internal/session's PrepareProgramFolder), its stdout is the
+// records, and its terminal record is the ending. Every stage, step and ending
+// is written to the task's action log (delegate.ActionsFile) the moment it is
+// received, which is what the task page draws the program's work from; its
+// `step` records are also what enter the trajectory, so the task page's step
+// count is what the program said it did and not how many phases it announced;
+// and the live step names the step of the program's process it is in.
+//
+// ── ITS ONLY ROAD TO A MODEL IS THIS RUN'S MODEL API ────────────────────────
+//
+// Before the program starts, the worker opens the run's model API
+// (internal/provider/modelapi) on this machine's loopback and hands the child
+// its address and token and nothing else (delegate.ChildEnv): no key reaches
+// the program. Every call it makes goes through the conversation's own
+// completer, is refused at the run's dollar ceiling before it is made, and is
+// written to the task's conversation log as one turn. The API is closed the
+// moment the program has exited, and the token dies with it.
+//
+// ── MONEY IS METERED BY THE API, NEVER REPORTED BY THE PROGRAM ──────────────
+//
+// Each call's price reaches four books as it is metered ([delegateMeter]):
+// the conversation's own, which folds the call whole — tokens, model and
+// dollars — without writing a ledger row of its own (internal/session's
+// beltFold); the run's live bank, which the supervisor holds to the ceiling;
+// the task's spend rows, one per call, which the task page draws; and this
+// machine's spending ledger, one row per call, exactly once, filed under the
+// conversation and the task.
+// The program's terminal record may still carry its own reading of what it
+// spent; that figure is kept on the record and never banked.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ lanes "github.com/Agent-Field/codeaf/internal/lane"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/provider/modelapi"
+ "github.com/Agent-Field/codeaf/internal/roles"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// delegateStderrName is the file a delegate's stderr is kept in, in the task's
+// own record folder beside the trajectory, because stderr is where a program
+// says why it could not start and a person opening the task should find it.
+const delegateStderrName = "delegate-stderr.log"
+
+// DelegateWorker runs one program as the worker of one task.
+type DelegateWorker struct {
+ store *plandb.Store
+ workspace string
+ program delegate.Delegate
+ setup DelegateSetup
+ // cost and elapsed are the run's ceilings, handed to the program on its
+ // command line so it cuts itself before the run has to. They are the
+ // factory's copy of the run's Limits: the supervisor enforces the same two
+ // from outside whatever the program does with them, and the model API
+ // refuses a call made past the dollar one.
+ cost float64
+ elapsed time.Duration
+}
+
+// DelegateSetup is how a delegated run starts its program's process and serves
+// it models.
+type DelegateSetup struct {
+ // Exe is codeaf's own executable, which the program runs as. Empty is this
+ // process's own; a test names a script that speaks the records.
+ Exe string
+ // Grace overrides the launch's SIGTERM grace, for a test.
+ Grace time.Duration
+ // CompleterFor answers the funnel a call on a model goes out through: the
+ // conversation's own completer (session.RunSpec.CompleterFor), so a
+ // program's calls take the road the conversation's own do. Nil is a run
+ // with no model road, whose API answers every call with that sentence.
+ CompleterFor func(model string) session.Completer
+ // Serves answers whether this conversation's services can take a call on a
+ // model (session.RunSpec.Serves); nil answers yes for every model.
+ Serves func(model string) bool
+ // Seat is the run's own work seat ([WorkSeat]): the model a call is
+ // answered on when the one the program asked for cannot be reached here.
+ Seat string
+ // Ledger is the spending ledger the calls are written to. Empty is this
+ // machine's own (session.UsageLedgerPath); a test names a file of its own.
+ Ledger string
+ // Keepalive overrides the model API's keepalive interval, for a test.
+ Keepalive time.Duration
+ // PlainFolder says the program works in its folder without git
+ // (session.RunSpec.PlainFolder), so the program's line carries its own
+ // flags for that (delegate.Delegate.PlainFolder).
+ PlainFolder bool
+ // Crew is the conversation's crew (session.RunSpec.Crew), which the
+ // program's line carries in its own flags (delegate.Delegate.CrewFlags) so
+ // it works on the models the person chose. Zero leaves it to its own.
+ Crew delegate.Crew
+ // Conversation is the id of the conversation the run belongs to
+ // (session.RunSpec.Conversation), which every ledger row the program's
+ // calls write names as its Root and its Session, beside the task's id, so
+ // the conversation's spend and the spending page can say whose money it
+ // was. Empty leaves the rows naming no conversation.
+ Conversation string
+ // OnCharge is told every priced call as it is metered
+ // (session.RunSpec.OnCharge), for the conversation to fold the call's
+ // tokens, model and dollars into its own books. Nil tells nobody.
+ OnCharge func(session.RunCharge)
+}
+
+// NewDelegateWorker builds the worker. cost and elapsed are the run's
+// ceilings, zero for none.
+func NewDelegateWorker(store *plandb.Store, workspace string, program delegate.Delegate, setup DelegateSetup, cost float64, elapsed time.Duration) *DelegateWorker {
+ return &DelegateWorker{store: store, workspace: workspace, program: program, setup: setup, cost: cost, elapsed: elapsed}
+}
+
+// DelegateFactory is the run's WorkerFactory for a delegated run: the root task
+// is the program's, and every other task the run seats — the review round's
+// check, and nothing else, because a delegated run is a run of one task —
+// falls to the factory it wraps, which is the crew's.
+func DelegateFactory(store *plandb.Store, workspace string, program delegate.Delegate, setup DelegateSetup, limits Limits, rest WorkerFactory) WorkerFactory {
+ return func(task plandb.Task) Worker {
+ if task.ID == store.RootID() {
+ return NewDelegateWorker(store, workspace, program, setup, limits.CostUSD, limits.Elapsed)
+ }
+ if rest == nil {
+ return nil
+ }
+ return rest(task)
+ }
+}
+
+// delegateSink is the delegate.Sink one run of the worker hands the launch: it
+// turns the stream into the store's live step, the trajectory's step lines and
+// the program record the task page reads. Its methods run on the reader's
+// goroutine and none of them waits on anything but the store's own lock.
+type delegateSink struct {
+ worker *DelegateWorker
+ taskID string
+ storeDir string
+ taskDir string
+ name string
+ steps int
+ lastErr error
+ terminal *delegate.Terminal
+ // stop ends the program early, and mismatch says why: the child spoke
+ // another protocol than this build's, which means codeaf was rebuilt while
+ // this conversation's engine was running and its child is the new build.
+ stop context.CancelFunc
+ mismatch string
+ // record is the program record as this run has written it so far: the
+ // name, the ceiling and the instant the process was started, and the
+ // stages once the hello has named them. It is kept here because the record
+ // is written whole, twice — at the hello and when the process is gone — and
+ // the second write must carry what the first one said.
+ record delegate.ProgramRecord
+ // reader is the program's own reader of its action log
+ // (delegate.Delegate.Reader), told every record in the order it arrives, so
+ // the live step can name the step of the program's process the record
+ // served; stepped is whether any record has named one yet, and step the
+ // word the live step reads now, which a record naming the same step again
+ // does not write twice.
+ reader delegate.ActionReader
+ stepped bool
+ step string
+}
+
+// remember writes one received record to the task's action log, stamped with
+// the moment it arrived, and moves the live step to the step it served.
+//
+// THE LOG IS A RECORD, SO A DISK THAT REFUSES IT COSTS THE PAGE AND NEVER THE
+// RUN, as the program record's does; and a child of ANOTHER BUILD is not this
+// run's program, so nothing it says is written down as the program's.
+func (s *delegateSink) remember(action delegate.Action) {
+ if s.mismatch != "" {
+ return
+ }
+ if strings.TrimSpace(s.taskDir) != "" {
+ _ = delegate.AppendAction(s.taskDir, action)
+ }
+ s.live(action)
+}
+
+// live moves the live step for one received record.
+//
+// THE LIVE STEP IS THE STEP OF THE PROGRAM'S PROCESS IT IS IN, numbered after
+// the last step recorded, so the row reads "senior-dev: explore" while the
+// program explores and the count on the row stays the steps'. The step is the
+// one the program's own reader of its log names for the record
+// (delegate.Delegate.Present) — a stage can name one as well as a step — and a
+// record that names none leaves the word standing.
+//
+// BEFORE ANY RECORD HAS NAMED A STEP, A STAGE IS SHOWN IN THE PROGRAM'S WORDS
+// FOR A PERSON, NOT ITS STAGE'S NAME. A program that says what a person should
+// read for its stages (delegate.Delegate's StageWords) is shown that word and
+// no status beside it — a status is its machinery too — and a stage it gave no
+// word keeps the word already shown. Only a program that said nothing is shown
+// its own names, as it spelled them.
+func (s *delegateSink) live(action delegate.Action) {
+ if s.reader == nil {
+ s.reader = s.worker.program.Reader()
+ }
+ if shown, ok := s.reader(action); ok && strings.TrimSpace(shown.Step) != "" {
+ s.stepped = true
+ if word := strings.TrimSpace(shown.Step); word != s.step {
+ s.step = word
+ _ = s.worker.store.SetLive(s.taskID, s.steps+1, s.name+": "+word)
+ }
+ return
+ }
+ if action.Kind != delegate.ActionStage || s.stepped {
+ return
+ }
+ label := s.name + ": " + action.Stage
+ if words := s.worker.program.StageWords; words != nil {
+ word := strings.TrimSpace(words[action.Stage])
+ if word == "" {
+ return
+ }
+ label = s.name + ": " + word
+ } else if action.Status != "" {
+ label += " · " + action.Status
+ }
+ _ = s.worker.store.SetLive(s.taskID, s.steps+1, label)
+}
+
+func (s *delegateSink) Hello(h delegate.Hello) {
+ if h.Protocol == delegate.ProtocolVersion {
+ // THE PAGE LEARNS WHOSE CONVERSATION IT IS DRAWING, the stages the
+ // program will move through and the ceiling its spend is read against,
+ // the moment the program says hello — and keeps knowing after the run.
+ // It is a record, so a disk that refuses it costs the page its heading
+ // and never the run.
+ s.record.Stages = h.Stages
+ _ = delegate.WriteProgram(s.taskDir, s.record)
+ return
+ }
+ // TWO BUILDS, ONE RUN. Nothing a newer child writes can be trusted to mean
+ // what this parent reads it as, so the run is stopped before it spends and
+ // the person is told the one thing that fixes it.
+ s.mismatch = fmt.Sprintf("codeaf was rebuilt while this conversation was open (its %s speaks version %d of the records, this one reads %d); restart codeaf to run %s",
+ s.name, h.Protocol, delegate.ProtocolVersion, s.name)
+ if s.stop != nil {
+ s.stop()
+ }
+}
+
+func (s *delegateSink) Stage(record delegate.StageRecord) {
+ s.remember(delegate.StageAction(time.Now(), record))
+}
+
+func (s *delegateSink) Step(record delegate.StepRecord) {
+ s.steps++
+ if err := appendTrajectory(s.storeDir, s.taskID, Step{
+ Kind: trajectoryStepKind,
+ Step: s.steps,
+ Command: record.Command,
+ Observation: observationHead(record.Observation),
+ }); err != nil && s.lastErr == nil {
+ s.lastErr = err
+ }
+ s.remember(delegate.StepAction(time.Now(), record))
+}
+
+func (s *delegateSink) Terminal(t delegate.Terminal) {
+ s.terminal = &t
+ s.remember(delegate.EndAction(time.Now(), t))
+}
+
+// delegateMeter is where the run's model API tells each charge as it is
+// metered: the conversation's books, the run's live bank, the task's spend
+// row, and the machine's spending ledger. It is called one charge at a time,
+// in order.
+type delegateMeter struct {
+ ctx context.Context
+ store *plandb.Store
+ taskID string
+ taskDir string
+ role string
+ name string
+ workspace string
+ ledger string
+ // conversation is the conversation the run belongs to, stamped on every
+ // ledger row; onCharge folds each call into that conversation's books.
+ conversation string
+ onCharge func(session.RunCharge)
+}
+
+// bank books one charge in all four places.
+//
+// THE CONVERSATION HEARS FIRST, BEFORE THE RUN'S BANK MOVES. The conversation
+// folds each call whole — its tokens, its model, its dollars — as it is
+// metered, and also folds whatever the run's total says it has not yet heard
+// of (internal/session's beltFold); telling it the call before the total that
+// holds the call is what keeps one dollar from being folded twice.
+//
+// THE LEDGER ROW IS WRITTEN HERE AND ONLY HERE. The conversation's fold writes
+// no ledger row, exactly as it does for a bash worker whose own session wrote
+// the rows — so each of the program's calls is on this machine's spending
+// ledger once. The row is the worker seat's, because the program sits where
+// the run's worker would, and it names whose work it was the way a task
+// node's row does ([session.UsageLine.Root]): the conversation as its Root and
+// its Session, the task as its Task. A row that named none of them was money
+// the conversation's receipt and the spending page could not place — 94.9% of
+// one day's spend on 2026-09-23 was senior-dev calls filed under nobody.
+func (m *delegateMeter) bank(charge modelapi.Charge) {
+ if m.onCharge != nil {
+ m.onCharge(session.RunCharge{
+ Model: charge.Model, TokensIn: charge.TokensIn, TokensOut: charge.TokensOut,
+ Cached: charge.Cached, USD: charge.CostUSD,
+ })
+ }
+ bankSpend(m.ctx, charge.Spent)
+ if err := m.store.AddSpend(m.taskID, m.name, m.role, charge.CostUSD, charge.TokensIn, charge.TokensOut); err != nil {
+ m.unstored(charge, err)
+ }
+ line := m.stamp(session.UsageLine{
+ Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD,
+ Reconciled: charge.Late,
+ })
+ session.RecordUsage(m.ledgerPath(), session.TagUsage(line, roles.RoleWorker, session.SeatWorker))
+}
+
+// unbilled keeps a call nobody could price on the ledger as the marker it is,
+// with no invented money, filed under the same work as every priced row.
+func (m *delegateMeter) unbilled(model string) {
+ session.RecordUnbilledCall(m.ledgerPath(), session.TagUsage(m.stamp(session.UsageLine{Model: model}), roles.RoleWorker, session.SeatWorker))
+}
+
+// stamp names whose work a ledger row is: the workspace it was spent against,
+// the task, and the conversation the task belongs to.
+func (m *delegateMeter) stamp(line session.UsageLine) session.UsageLine {
+ line.Workspace = m.workspace
+ line.Task = strings.TrimPrefix(strings.TrimSpace(m.taskID), "t-")
+ if conversation := strings.TrimSpace(m.conversation); conversation != "" {
+ line.Root, line.Session = conversation, conversation
+ }
+ return line
+}
+
+// unstored says, in the task's own record folder, that a charge could not be
+// written to the task's spend rows.
+//
+// A SPEND ROW THE STORE REFUSED IS NOT DROPPED IN SILENCE. The machine's
+// ledger and the conversation's books already hold the charge, but the task
+// page's figure is read from these rows, so a refusal makes the page read
+// short; the line in delegate-stderr.log is where a person asking why finds
+// the answer. It is written only after the model API has closed — a receipt
+// that outlived even its wait, arriving after the program's process is gone —
+// or on a store that failed outright, so it never interleaves with the
+// program's own stderr.
+func (m *delegateMeter) unstored(charge modelapi.Charge, err error) {
+ if strings.TrimSpace(m.taskDir) == "" {
+ return
+ }
+ file, openErr := os.OpenFile(filepath.Join(m.taskDir, delegateStderrName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if openErr != nil {
+ return
+ }
+ defer file.Close()
+ model := strings.TrimSpace(charge.Model)
+ if model == "" {
+ model = "a model"
+ }
+ _, _ = fmt.Fprintf(file, "codeaf: a charge of $%.6f for a call on %s is not in this task's spend rows, because the task's record refused it (%v); the machine's spending ledger has it\n",
+ charge.CostUSD, model, err)
+}
+
+func (m *delegateMeter) ledgerPath() string {
+ if strings.TrimSpace(m.ledger) != "" {
+ return m.ledger
+ }
+ return session.UsageLedgerPath()
+}
+
+// Run starts the program and reads it to its ending. The Report's Result is
+// the ending in words a person reads; Steps is what the program said it did;
+// USD is what the model API metered, and nothing the program said about it.
+func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, error) {
+ storeDir := filepath.Dir(w.store.Path())
+ taskDir := plandb.TaskDir(storeDir, task.ID)
+ if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryBeginKind, ExitsRecorded: true}); err != nil {
+ return Report{}, fmt.Errorf("stamp the trajectory opening line: %w", err)
+ }
+ // THE PROGRAM'S OWN CLOCK: the instant its process was started and the
+ // instant it was gone, both zero on every road out of here that never
+ // started one. The ending line carries them, so the trajectory holds the
+ // same pair the program record does.
+ var started, ended time.Time
+ end := func(steps int, reason, result string) {
+ _ = appendTrajectory(storeDir, task.ID, Step{
+ Kind: trajectoryEndKind, ExitsRecorded: true, Steps: steps, Result: result, Reason: reason,
+ StartedAt: started, EndedAt: ended,
+ })
+ }
+ exe := w.setup.Exe
+ if exe == "" {
+ self, err := os.Executable()
+ if err != nil {
+ reason := fmt.Sprintf("find codeaf's own executable to run %s: %v", w.program.Name, err)
+ end(0, reason, "")
+ return Report{}, errors.New(reason)
+ }
+ exe = self
+ }
+ role, err := w.store.RoleOf(task.ID)
+ if err != nil {
+ role = plandb.RoleWork
+ }
+ meter := &delegateMeter{
+ ctx: ctx, store: w.store, taskID: task.ID, taskDir: taskDir, role: role,
+ // The spend row's "model" column carries the program's name, because
+ // that is what spent the money; the ledger row names the model that
+ // answered.
+ name: "delegate/" + w.program.Name,
+ workspace: w.workspace, ledger: w.setup.Ledger,
+ conversation: w.setup.Conversation, onCharge: w.setup.OnCharge,
+ }
+ api, err := modelapi.Open(modelapi.Config{
+ TaskDir: taskDir,
+ CompleterFor: w.completerFor(),
+ Serves: w.setup.Serves,
+ Seat: w.setup.Seat,
+ Ceiling: w.cost,
+ Bank: meter.bank,
+ Unbilled: meter.unbilled,
+ // THE LIVE STEP GOES WITH THE PROCESS, EVEN WHILE ITS LAST PRICE IS
+ // OWED. The API's close waits for a cut call's receipt after the program
+ // has exited, and a row reading "implement · running" through that wait
+ // would claim a present that is over.
+ Settling: func(int) { _ = w.store.ClearLive(task.ID) },
+ // NOBODY IS READING THE PROGRAM'S CALLS AS THEY ARRIVE: it is a task's
+ // worker, and the person is in their conversation or away from it.
+ Role: lanes.RoleLeafUnattended,
+ Node: w.program.Name,
+ Keepalive: w.setup.Keepalive,
+ })
+ if err != nil {
+ reason := fmt.Sprintf("open %s's model API: %v", w.program.Name, err)
+ end(0, reason, "")
+ return Report{}, errors.New(reason)
+ }
+ // THE TOKEN DIES WITH THE RUN, on every path out of this function; the
+ // ordinary path closes it the moment the program has exited, below.
+ defer func() { _ = api.Close() }()
+
+ launchCtx, stop := context.WithCancel(ctx)
+ defer stop()
+ sink := &delegateSink{worker: w, taskID: task.ID, storeDir: storeDir, taskDir: taskDir, name: w.program.Name, stop: stop,
+ record: delegate.ProgramRecord{Name: w.program.Name, CeilingUSD: w.cost}}
+ brief := strings.TrimSpace(task.Description)
+ if brief == "" {
+ brief = strings.TrimSpace(task.Title)
+ }
+ started = time.Now()
+ sink.record.StartedAt = started
+ result, err := delegate.Run(launchCtx, delegate.Launch{
+ Name: w.program.Name,
+ Bin: exe,
+ Args: delegate.ChildArgs(w.program, w.workspace, brief, delegate.Ceilings{CostUSD: w.cost, Hours: w.elapsed.Hours()},
+ delegate.RunFacts{Plain: w.setup.PlainFolder, Crew: w.setup.Crew}),
+ // NO KEY REACHES THE PROGRAM (delegate.ChildEnv): the API's address and
+ // token are the whole of what it is given.
+ Env: delegate.ChildEnv(api.API()),
+ Dir: w.workspace,
+ StderrPath: filepath.Join(taskDir, delegateStderrName),
+ Grace: w.setup.Grace,
+ }, sink)
+ // THE INSTANT THE PROCESS WAS GONE, and not the instant its stdout drained
+ // ([delegate.Result.ExitedAt] says why; a shell run reads it the same way).
+ ended = result.ExitedAt(started, time.Now())
+ // THE RECORD IS WRITTEN AGAIN NOW, WHOLE, AND WHETHER OR NOT A HELLO CAME. A
+ // program that died before it said hello is still a program this run
+ // started, and its page and its row need its times as much as a finished
+ // one's do. A child of ANOTHER BUILD is the one exception: it was never this
+ // run's program, and it is not written down as one.
+ if sink.mismatch == "" {
+ sink.record.EndedAt = ended
+ _ = delegate.WriteProgram(taskDir, sink.record)
+ }
+ // The program has exited: its API goes with it, so nothing it left behind
+ // can spend, and the calls that were still running write their last turn.
+ // THE CLOSE WAITS FOR THE RECEIPTS STILL OWED (modelapi's Server.Close): the
+ // call a stop or the ceiling cut in the middle is priced about twenty
+ // seconds later, and it has to reach the task's spend rows, the run's total
+ // read just below and the conversation's books while all three are open.
+ _ = api.Close()
+ // THE LIVE STEP GOES WITH THE PROCESS, whatever the ending: a row that still
+ // read "implement · running" after the program was gone would be a claim
+ // about a present that is over.
+ _ = w.store.ClearLive(task.ID)
+
+ report := Report{Steps: sink.steps, USD: api.Spent()}
+ if sink.lastErr != nil {
+ end(sink.steps, "the record failed: "+sink.lastErr.Error(), "")
+ return report, sink.lastErr
+ }
+ if sink.mismatch != "" && ctx.Err() == nil {
+ end(sink.steps, sink.mismatch, "")
+ return report, errors.New(sink.mismatch)
+ }
+ if result.Stopped {
+ // THE RUN'S OWN ENDING CUT THIS PROGRAM: the context is what ended it, so
+ // the error is the context's own and the supervisor records the cut. A
+ // terminal the program wrote inside the grace still names the reason.
+ reason := "stopped by the run"
+ if t := result.Reading.Terminal; t != nil && t.Message != "" {
+ reason += ": " + w.program.Name + " said " + t.Message
+ }
+ end(sink.steps, reason, "")
+ return report, err
+ }
+ // THE CEILING, NOT A CRASH. A program the model API refused at the run's
+ // dollar ceiling ends however it ends — senior-dev, whose own sum of its
+ // answers' costs never reached the figure it was given, ends as `crashed` —
+ // but what stopped it was the limit a person set, and the run says so. The
+ // supervisor's own ledger has reached the same ceiling, so the run ends on
+ // its cost limit; this is the worker's half, the words the task keeps.
+ if t := result.Reading.Terminal; api.RefusedAtCeiling() > 0 && (t == nil || t.Status != delegate.StatusPass) {
+ reason := fmt.Sprintf("%s reached the run's dollar ceiling of $%.2f", w.program.Name, w.cost)
+ if t != nil {
+ report.Result = delegateResult(w.program, *t)
+ if message := strings.TrimSpace(t.Message); message != "" {
+ reason += ": " + w.program.Name + " said " + message
+ }
+ }
+ end(sink.steps, reason, report.Result)
+ return report, errors.New(reason)
+ }
+ if errors.Is(err, delegate.ErrNoTerminal) {
+ reason := fmt.Sprintf("%s exited %d without a terminal record", w.program.Name, result.ExitCode)
+ if result.Reading.LastStage != "" {
+ reason += "; its last stage was " + result.Reading.LastStage
+ }
+ end(sink.steps, reason, "")
+ return report, errors.New(reason)
+ }
+ if err != nil {
+ end(sink.steps, err.Error(), "")
+ return report, err
+ }
+ t := *result.Reading.Terminal
+ report.Result = delegateResult(w.program, t)
+ var reason string
+ switch t.Status {
+ case delegate.StatusPass:
+ report.Verdict = t.Verdict()
+ end(sink.steps, "finished: "+t.Message, report.Result)
+ return report, nil
+ case delegate.StatusBudget:
+ reason = w.program.Name + " stopped on its own ceiling: " + t.Message
+ case delegate.StatusCrashed:
+ reason = w.program.Name + " crashed: " + t.Message
+ default:
+ // `fail`, and any word this build does not know, is work that does not
+ // stand: the run reads it as incomplete.
+ reason = w.program.Name + " did not finish: " + t.Message
+ }
+ end(sink.steps, reason, report.Result)
+ return report, &ProgramEndedError{Status: t.Status, Reason: reason, Result: report.Result}
+}
+
+// ProgramEndedError is a program's own ending when it did not finish: the
+// status word its terminal record carried, the sentence the task keeps, and
+// its account in full. The run carries it to the session whole
+// ([Summary.Program]), which draws the row from the fact rather than from the
+// generic "ran and did not finish" — the row that said only that, over an hour
+// of work that had submitted a change and said exactly why it would not
+// stand, told a person nothing they could act on.
+type ProgramEndedError struct {
+ // Status is the terminal record's word: fail, budget, crashed, or one
+ // this build does not know.
+ Status string
+ // Reason is the one sentence: `senior-dev did not finish: …`.
+ Reason string
+ // Result is the program's account: its message, what its model claimed
+ // and what it observed ([delegateResult]).
+ Result string
+}
+
+func (e *ProgramEndedError) Error() string { return e.Reason }
+
+// completerFor is the setup's completer factory in the model API's own
+// words, each completer marked so a call keeps the program's own cache
+// lineage (session.WithOwnCacheLineage): a program's conversations are its
+// own, and the conversation's key stamped over them would put every one of
+// them on the conversation's warm instance.
+func (w *DelegateWorker) completerFor() func(model string) modelapi.Completer {
+ if w.setup.CompleterFor == nil {
+ return nil
+ }
+ return func(model string) modelapi.Completer {
+ completer := w.setup.CompleterFor(model)
+ if completer == nil {
+ return nil
+ }
+ return ownLineage{completer}
+ }
+}
+
+// ownLineage is a completer whose calls keep the cache key already on their
+// context.
+type ownLineage struct{ inner session.Completer }
+
+func (c ownLineage) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ return c.inner.CompleteWithMessages(session.WithOwnCacheLineage(ctx), messages, options...)
+}
+
+// delegateResult is the ending in words: the deliverable for a program that
+// lands text, and for one that lands a tree the program's message with the
+// claim and the observation as two sentences, kept apart because the
+// program's model and the program itself are two witnesses.
+func delegateResult(m delegate.Delegate, t delegate.Terminal) string {
+ if !m.LandsTree() {
+ if deliverable := t.Deliverable(); deliverable != "" {
+ return deliverable
+ }
+ }
+ parts := []string{strings.TrimSpace(t.Message)}
+ if claim := t.Claim(); claim != "" {
+ parts = append(parts, m.Name+"'s model said: "+claim)
+ }
+ if observed := t.Observed(); observed != "" {
+ parts = append(parts, m.Name+" observed: "+observed)
+ }
+ if reason := t.Reason(); reason != "" && reason != t.Message {
+ parts = append(parts, reason)
+ }
+ return strings.Join(nonEmpty(parts), ". ")
+}
+
+func nonEmpty(parts []string) []string {
+ out := parts[:0]
+ for _, p := range parts {
+ if strings.TrimSpace(p) != "" {
+ out = append(out, strings.TrimRight(strings.TrimSpace(p), "."))
+ }
+ }
+ return out
+}
diff --git a/internal/run/delegateworker_clock_test.go b/internal/run/delegateworker_clock_test.go
new file mode 100644
index 0000000000..a8494f352f
--- /dev/null
+++ b/internal/run/delegateworker_clock_test.go
@@ -0,0 +1,73 @@
+//go:build !windows
+
+package run_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/run"
+)
+
+// THE PROGRAM'S OWN CLOCK IS WRITTEN DOWN. A run's wall time had nothing to
+// stand on: the store is seeded before the copy is cut and the row settles
+// after the landing, so every surface reconstructed a span from a different
+// pair of instants and none of them was the program's. The worker now stamps
+// the instant it started the process and the instant the process was gone on
+// the program record — keeping the hello's name, stages and ceiling — and on
+// the trajectory's ending line.
+func TestDelegateWorkerStampsTheProgramsOwnClock(t *testing.T) {
+ store := runOpenStore(t)
+ storeDir := filepath.Dir(store.Path())
+ m, setup := fakeDelegate(t, "sleep 0.2\n"+passLine("tests are green"))
+ worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 2.5, 0)
+ before := time.Now()
+ if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil {
+ t.Fatalf("the delegate's run failed: %v", err)
+ }
+ after := time.Now()
+
+ record, ok := delegate.ReadProgram(plandb.TaskDir(storeDir, store.RootID()))
+ if !ok || record.Name != "fake" || strings.Join(record.Stages, ",") != "implement,verify" || record.CeilingUSD != 2.5 {
+ t.Fatalf("program record = %+v %v, want the hello's name and stages and the run's ceiling kept", record, ok)
+ }
+ if record.StartedAt.IsZero() || record.EndedAt.IsZero() {
+ t.Fatalf("program record carries no clock: started %v ended %v", record.StartedAt, record.EndedAt)
+ }
+ if record.StartedAt.Before(before) || record.EndedAt.After(after) || record.EndedAt.Sub(record.StartedAt) < 200*time.Millisecond {
+ t.Fatalf("program clock %v → %v is not the process's life inside the run's %v → %v", record.StartedAt, record.EndedAt, before, after)
+ }
+ end := endLine(t, rawTrajectory(t, storeDir, store.RootID()))
+ if !end.StartedAt.Equal(record.StartedAt) || !end.EndedAt.Equal(record.EndedAt) {
+ t.Fatalf("the ending line's clock %v → %v is not the record's %v → %v", end.StartedAt, end.EndedAt, record.StartedAt, record.EndedAt)
+ }
+}
+
+// A PROGRAM THAT DIED BEFORE ITS HELLO STILL HAS ITS TIMES. The record used to
+// be written at the hello and nowhere else, so a program that fell over on its
+// first line left no record at all, and its page and its row had nothing to
+// measure it by.
+func TestDelegateWorkerRecordsTheClockOfAProgramThatNeverSaidHello(t *testing.T) {
+ store := runOpenStore(t)
+ storeDir := filepath.Dir(store.Path())
+ script := filepath.Join(t.TempDir(), "dies.sh")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'no such flag' >&2\nexit 3\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ worker := run.NewDelegateWorker(store, t.TempDir(), delegate.Delegate{Name: "fake", Default: "run"}, run.DelegateSetup{Exe: script}, 0, 0)
+ if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err == nil || !strings.Contains(err.Error(), "fake exited 3 without a terminal record") {
+ t.Fatalf("err = %v, want the exit named", err)
+ }
+ record, ok := delegate.ReadProgram(plandb.TaskDir(storeDir, store.RootID()))
+ if !ok || record.Name != "fake" || len(record.Stages) != 0 {
+ t.Fatalf("program record = %+v %v, want the program named with no stages it never said", record, ok)
+ }
+ if record.StartedAt.IsZero() || record.EndedAt.IsZero() || record.EndedAt.Before(record.StartedAt) {
+ t.Fatalf("program clock = %v → %v, want both instants in order", record.StartedAt, record.EndedAt)
+ }
+}
diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go
new file mode 100644
index 0000000000..875f497e22
--- /dev/null
+++ b/internal/run/delegateworker_test.go
@@ -0,0 +1,542 @@
+//go:build !windows
+
+package run_test
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/provider"
+ "github.com/Agent-Field/codeaf/internal/run"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// fakeDelegate writes a shell program that stands in for codeaf running a
+// program — a hello, a stage, a v1 spend line (which nothing reads any more),
+// two steps, then body — and answers the program's definition and the setup
+// that starts the script in codeaf's place.
+func fakeDelegate(t *testing.T, body string) (delegate.Delegate, run.DelegateSetup) {
+ t.Helper()
+ script := filepath.Join(t.TempDir(), "fake.sh")
+ program := "#!/bin/sh\n" + strings.Join([]string{
+ `if [ -n "$FAKE_ARGS" ]; then printf '%s\n' "$@" > "$FAKE_ARGS"; fi`,
+ `echo '{"type":"hello","protocol":2,"delegate":"fake","stages":["implement","verify"]}'`,
+ `echo '{"type":"stage","stage":"implement","status":"running"}'`,
+ `echo '{"type":"spend","cost_usd":0.05}'`,
+ `echo '{"type":"step","command":"bash: go test ./...","observation":"ok"}'`,
+ `echo '{"type":"step","command":"edit: a.go"}'`,
+ body,
+ }, "\n") + "\n"
+ if err := os.WriteFile(script, []byte(program), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return delegate.Delegate{Name: "fake", Summary: "a fake program", Default: "run"}, run.DelegateSetup{Exe: script}
+}
+
+func passLine(claim string) string {
+ return `echo '{"type":"terminal","status":"pass","message":"submitted and verified","data":{"cost_usd":0.12,"submission_reason":"` + claim + `","status":"pass"}}'`
+}
+
+// funnel is the conversation's completer as a test writes it: every call is
+// answered with words and billed at cost, the way the provider's decode bills
+// one, and every model it was handed is kept.
+type funnel struct {
+ mu sync.Mutex
+ cost float64
+ models []string
+}
+
+func (f *funnel) completerFor(string) session.Completer { return f }
+
+func (f *funnel) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
+ var request ai.Request
+ for _, option := range options {
+ _ = option(&request)
+ }
+ f.mu.Lock()
+ f.models = append(f.models, request.Model)
+ f.mu.Unlock()
+ if sink := provider.BillingSinkFrom(ctx); sink != nil {
+ sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, CachedTokens: 60, Cost: f.cost})
+ }
+ question := ""
+ if last := messages[len(messages)-1]; len(last.Content) > 0 {
+ question = last.Content[0].Text
+ }
+ return &ai.Response{Model: request.Model, Choices: []ai.Choice{{
+ Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "answered " + question}}},
+ FinishReason: "stop",
+ }}}, nil
+}
+
+func (f *funnel) seen() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.models...)
+}
+
+// realChild is the setup that starts THIS test binary as the program's process
+// (delegate_child_test.go), with the model API served over a funnel costing
+// cost a call and a spending ledger of the test's own.
+func realChild(t *testing.T, cost float64, calls string) (delegate.Delegate, run.DelegateSetup, *funnel, string) {
+ t.Helper()
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(delegateChildEnv, "1")
+ t.Setenv("FAKE_CALLS", calls)
+ ledger := filepath.Join(t.TempDir(), "usage.jsonl")
+ calling := &funnel{cost: cost}
+ return childProgram(), run.DelegateSetup{Exe: self, CompleterFor: calling.completerFor, Ledger: ledger, Grace: 5 * time.Second}, calling, ledger
+}
+
+// ledgerRows is the spending ledger's rows, once the writer has drained.
+func ledgerRows(t *testing.T, path string) []session.UsageLine {
+ t.Helper()
+ session.FlushUsage()
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("the spending ledger was never written: %v", err)
+ }
+ defer file.Close()
+ var rows []session.UsageLine
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ var row session.UsageLine
+ if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
+ t.Fatalf("a ledger row does not parse: %s", scanner.Bytes())
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+// THE WHOLE ROAD, WITH A REAL CHILD: the worker opens the run's model API,
+// starts the program as a process of its own with the API's address and token
+// and no key, the program asks it two questions over a real socket, and every
+// call is metered into all three books as it happens — the run's bank, the
+// task's spend rows and the machine's ledger, once each — and written down as
+// a turn of the program's conversation. The program's own claim about what it
+// spent is never believed, and the token is dead the moment the program is.
+func TestDelegateWorkerServesItsChildTheModelAPIAndMetersEveryCall(t *testing.T) {
+ store := runOpenStore(t)
+ storeDir := filepath.Dir(store.Path())
+ taskDir := plandb.TaskDir(storeDir, store.RootID())
+ program, setup, calling, ledger := realChild(t, 0.05, "2")
+ apiFile := filepath.Join(t.TempDir(), "api")
+ envFile := filepath.Join(t.TempDir(), "env")
+ t.Setenv("FAKE_API_FILE", apiFile)
+ t.Setenv("FAKE_ENV", envFile)
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-the-parents-own-key")
+ workspace := t.TempDir()
+ worker := run.NewDelegateWorker(store, workspace, program, setup, 2.5, 0)
+
+ var mu sync.Mutex
+ var banked []float64
+ ctx := run.WithSpendBank(runContext(t), func(usd float64) {
+ mu.Lock()
+ defer mu.Unlock()
+ banked = append(banked, usd)
+ })
+ report, err := worker.Run(ctx, *store.Task(store.RootID()))
+ if err != nil {
+ stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log"))
+ t.Fatalf("the delegate's run failed: %v\nstderr:\n%s", err, stderr)
+ }
+ if report.Steps != 2 || !strings.Contains(report.Result, "fake's model said: all green") {
+ t.Fatalf("report = %+v", report)
+ }
+ // THE METER'S FIGURE, NOT THE PROGRAM'S 99.
+ if report.USD != 0.1 {
+ t.Fatalf("usd = %v, want the two metered calls' 0.10", report.USD)
+ }
+ mu.Lock()
+ if len(banked) != 2 || banked[0] != 0.05 || banked[1] != 0.1 {
+ t.Fatalf("banked = %v, want the run's total rising call by call", banked)
+ }
+ mu.Unlock()
+ if spend := store.SpendSummary().ByModel["delegate/fake"]; spend.USD != 0.1 || spend.Calls != 2 {
+ t.Fatalf("spend rows = %+v, want one per call under the program's name", store.SpendSummary().ByModel)
+ }
+ rows := ledgerRows(t, ledger)
+ if len(rows) != 2 {
+ t.Fatalf("ledger rows = %+v, want exactly one per call", rows)
+ }
+ for _, row := range rows {
+ if row.Model != "deepseek/deepseek-v4-flash-0731" || row.USD != 0.05 || row.Input != 100 || row.Calls != 1 || row.Seat != session.SeatWorker || row.Workspace != workspace {
+ t.Fatalf("ledger row = %+v", row)
+ }
+ }
+ // The conversation: two turns, what the program said and what came back.
+ turns, err := delegate.ReadTurns(taskDir, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(turns) != 2 || turns[0].Reply != "answered call 1: drive the plan to the ground" || turns[1].CostUSD != 0.05 || turns[1].Cached != 60 {
+ t.Fatalf("turns = %+v", turns)
+ }
+ if len(turns[0].Sent) != 2 || turns[0].Sent[0].Role != "system" || turns[1].Restarted != true {
+ // The fake asks each question on a fresh two-message history, which
+ // is a history rewritten — said so, and sent whole.
+ t.Fatalf("sent = %+v / restarted %v", turns[0].Sent, turns[1].Restarted)
+ }
+ if record, ok := delegate.ReadProgram(taskDir); !ok || record.Name != "fake" || strings.Join(record.Stages, ",") != "implement,verify" || record.CeilingUSD != 2.5 {
+ t.Fatalf("program record = %+v %v, want the hello's name and stages and the run's ceiling", record, ok)
+ }
+ if models := calling.seen(); len(models) != 2 || models[0] != "deepseek/deepseek-v4-flash-0731" {
+ t.Fatalf("the funnel was asked for %q", models)
+ }
+ // NO KEY REACHED THE PROGRAM, and the API it was given is dead now.
+ environ, _ := os.ReadFile(envFile)
+ if strings.Contains(string(environ), "sk-or-v1-the-parents-own-key") || !strings.Contains(string(environ), delegate.EnvModelToken+"=") {
+ t.Fatalf("the child's environment:\n%s", environ)
+ }
+ api, _ := os.ReadFile(apiFile)
+ base, token, _ := strings.Cut(strings.TrimSpace(string(api)), "\n")
+ if !strings.HasPrefix(base, "http://127.0.0.1:") || token == "" {
+ t.Fatalf("the child was handed %q", api)
+ }
+ request, _ := http.NewRequest(http.MethodPost, base+"/chat/completions", strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`))
+ request.Header.Set("Authorization", "Bearer "+token)
+ if response, err := http.DefaultClient.Do(request); err == nil {
+ response.Body.Close()
+ t.Fatalf("the run's token still opens its API after the run: %d", response.StatusCode)
+ }
+}
+
+// A CHILD THAT CURLS THE API — the way any program outside codeaf's tree
+// would — is served by the worker, its call metered and written down, and the
+// token it was handed opens nothing once the run has ended.
+func TestDelegateWorkerServesAChildThatCurlsTheAPIAndCutsItOffAfter(t *testing.T) {
+ if _, err := exec.LookPath("curl"); err != nil {
+ t.Skip("no curl on this machine")
+ }
+ store := runOpenStore(t)
+ taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())
+ saved := filepath.Join(t.TempDir(), "saved")
+ reply := filepath.Join(t.TempDir(), "reply")
+ t.Setenv("FAKE_SAVED", saved)
+ t.Setenv("FAKE_REPLY", reply)
+ m, setup := fakeDelegate(t, strings.Join([]string{
+ `printf '%s\n%s\n' "$CODEAF_MODEL_API" "$CODEAF_MODEL_TOKEN" > "$FAKE_SAVED"`,
+ `curl -sS -X POST "$CODEAF_MODEL_API/chat/completions" -H "Authorization: Bearer $CODEAF_MODEL_TOKEN" -H "Content-Type: application/json" ` +
+ `-d '{"model":"z-ai/glm-5.1","messages":[{"role":"user","content":"is it green"}]}' > "$FAKE_REPLY"`,
+ passLine("curl was answered"),
+ }, "\n"))
+ calling := &funnel{cost: 0.03}
+ setup.CompleterFor = calling.completerFor
+ setup.Ledger = filepath.Join(t.TempDir(), "usage.jsonl")
+ var banked []float64
+ ctx := run.WithSpendBank(runContext(t), func(usd float64) { banked = append(banked, usd) })
+ report, err := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0).Run(ctx, *store.Task(store.RootID()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ answered, _ := os.ReadFile(reply)
+ if !strings.Contains(string(answered), `"content":"answered is it green"`) || !strings.Contains(string(answered), `"cost":0.03`) {
+ t.Fatalf("curl was answered %s", answered)
+ }
+ if report.USD != 0.03 || len(banked) != 1 || banked[0] != 0.03 {
+ t.Fatalf("usd %v banked %v, want the one metered call", report.USD, banked)
+ }
+ if turns, _ := delegate.ReadTurns(taskDir, 0); len(turns) != 1 || turns[0].Model != "z-ai/glm-5.1" || turns[0].Sent[0].Text != "is it green" {
+ t.Fatalf("turns = %+v", turns)
+ }
+ lines, _ := os.ReadFile(saved)
+ base, token, _ := strings.Cut(strings.TrimSpace(string(lines)), "\n")
+ after := exec.Command("curl", "-sS", "--max-time", "5", "-X", "POST", base+"/chat/completions",
+ "-H", "Authorization: Bearer "+token, "-d", `{"messages":[{"role":"user","content":"again"}]}`)
+ if out, err := after.CombinedOutput(); err == nil {
+ t.Fatalf("the token still opened the API after the run:\n%s", out)
+ }
+}
+
+// THE PROGRAM'S OWN WORD ABOUT MONEY IS NOT MONEY: a run whose program made no
+// call through the API spent nothing, whatever its spend lines and its
+// terminal said, and leaves no spend row.
+func TestDelegateWorkerRecordsStepsAndBelievesNoSpendItWasTold(t *testing.T) {
+ store := runOpenStore(t)
+ storeDir := filepath.Dir(store.Path())
+ args := filepath.Join(t.TempDir(), "args")
+ t.Setenv("FAKE_ARGS", args)
+ workspace := t.TempDir()
+ m, setup := fakeDelegate(t, passLine("tests are green"))
+ worker := run.NewDelegateWorker(store, workspace, m, setup, 2.5, 0)
+
+ var banked []float64
+ ctx := run.WithSpendBank(runContext(t), func(usd float64) { banked = append(banked, usd) })
+ report, err := worker.Run(ctx, *store.Task(store.RootID()))
+ if err != nil {
+ t.Fatalf("the delegate's run failed: %v", err)
+ }
+ if report.Steps != 2 {
+ t.Fatalf("steps = %d, want the two step records the program sent", report.Steps)
+ }
+ if report.USD != 0 || len(banked) != 0 {
+ t.Fatalf("usd %v banked %v, want nothing: no call was metered", report.USD, banked)
+ }
+ if spend := store.SpendSummary(); len(spend.ByModel) != 0 {
+ t.Fatalf("spend rows = %+v, want none", spend.ByModel)
+ }
+ if !strings.Contains(report.Result, "submitted and verified") || !strings.Contains(report.Result, "fake's model said: tests are green") || !strings.Contains(report.Result, "fake observed: pass") {
+ t.Fatalf("result = %q, want the message, the claim and the observation as separate sentences", report.Result)
+ }
+ // The brief the program was handed is the task's description, and the
+ // ceiling is the run's.
+ got, _ := os.ReadFile(args)
+ if want := "fake\nrun\n--json\n--dir\n" + workspace + "\n--max-cost\n2.5\n--\ndrive the plan to the ground\n"; string(got) != want {
+ t.Fatalf("argv =\n%s\nwant\n%s", got, want)
+ }
+ // The trajectory: the opening line, two steps, the ending.
+ steps, err := run.Trajectory(storeDir, store.RootID())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(steps) != 2 || steps[0].Command != "bash: go test ./..." || steps[0].Observation != "ok" || steps[1].Step != 2 {
+ t.Fatalf("trajectory steps = %+v", steps)
+ }
+ lines := rawTrajectory(t, storeDir, store.RootID())
+ if len(lines) != 4 {
+ t.Fatalf("the trajectory holds %d lines, want the opening, two steps and the ending", len(lines))
+ }
+ end := endLine(t, lines)
+ if end.Steps != 2 || !strings.HasPrefix(end.Reason, "finished: ") {
+ t.Fatalf("ending = %+v", end)
+ }
+ // The live step was cleared with the process, stderr went to the task's
+ // folder, and the hello left the program's record beside it.
+ if live := store.LiveSteps(); len(live) != 0 {
+ t.Fatalf("live steps = %+v, want none after the program ended", live)
+ }
+ taskDir := plandb.TaskDir(storeDir, store.RootID())
+ if _, err := os.Stat(filepath.Join(taskDir, "delegate-stderr.log")); err != nil {
+ t.Fatalf("no stderr file beside the trajectory: %v", err)
+ }
+ if record, ok := delegate.ReadProgram(taskDir); !ok || record.Name != "fake" || len(record.Stages) != 2 {
+ t.Fatalf("program record = %+v %v", record, ok)
+ }
+}
+
+func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"unsubmitted","data":{"cost_usd":0.2,"status":"unsubmitted"}}'`)
+ worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0)
+ report, err := worker.Run(runContext(t), *store.Task(store.RootID()))
+ if err == nil || !strings.Contains(err.Error(), "fake did not finish: unsubmitted") {
+ t.Fatalf("err = %v", err)
+ }
+ // The steps are kept on a failed ending; the terminal's $0.20 is the
+ // program's own word and is not money.
+ if report.USD != 0 || report.Steps != 2 {
+ t.Fatalf("report = %+v, want the steps kept and nothing banked on the program's word", report)
+ }
+}
+
+// A program that ended without finishing says why, and the run carries its
+// words whole to whoever drew the row: its status word, its sentence and its
+// account, not only the run's one word for every unfinished ending.
+func TestARunCarriesTheProgramsOwnEndingWhenItDidNotFinish(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"submitted a change the project tests do not pass","data":{"submission_reason":"all done","status":"fail"}}'`)
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store, Workspace: t.TempDir(), Title: "The run", Brief: "drive the plan to the ground", Slots: 1,
+ Factory: run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{}, nil),
+ })
+ if outcome != run.OutcomeIncomplete {
+ t.Fatalf("outcome = %q, want incomplete", outcome)
+ }
+ ended := summary.Program
+ if ended == nil || ended.Status != delegate.StatusFail ||
+ ended.Reason != "fake did not finish: submitted a change the project tests do not pass" ||
+ !strings.Contains(ended.Result, "fake's model said: all done") {
+ t.Fatalf("the run's program ending = %+v, want the program's own status, sentence and account", ended)
+ }
+}
+
+func TestDelegateWorkerNamesAnExitWithoutATerminal(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup := fakeDelegate(t, "exit 7")
+ worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0)
+ _, err := worker.Run(runContext(t), *store.Task(store.RootID()))
+ if err == nil || err.Error() != "fake exited 7 without a terminal record; its last stage was implement" {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup := fakeDelegate(t, strings.Join([]string{
+ `trap 'echo "{\"type\":\"terminal\",\"status\":\"budget-exhausted\",\"message\":\"told to stop\",\"data\":{\"cost_usd\":0.11}}"; exit 0' TERM`,
+ `sleep 30 &`,
+ `wait $!`,
+ }, "\n"))
+ worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0)
+ ctx, cancel := context.WithCancel(runContext(t))
+ go func() {
+ // Once the store has the program's live step, the program is past its
+ // trap line and the signal will be caught.
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if live := store.LiveSteps(); len(live) > 0 {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+ }()
+ report, err := worker.Run(ctx, *store.Task(store.RootID()))
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err = %v, want the context's own so the run records the cut", err)
+ }
+ if report.USD != 0 {
+ t.Fatalf("usd = %v, want nothing: the program made no metered call", report.USD)
+ }
+ lines := rawTrajectory(t, filepath.Dir(store.Path()), store.RootID())
+ end := endLine(t, lines)
+ if end.Reason != "stopped by the run: fake said told to stop" {
+ t.Fatalf("ending reason = %q", end.Reason)
+ }
+}
+
+// The whole road: a run of one task whose root is the delegate — a real child
+// calling the real API — driven by the supervisor to done, with the
+// delegate's words as the run's result and the metered calls as its dollars.
+func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup, _, _ := realChild(t, 0.06, "2")
+ factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 5}, nil)
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store,
+ Workspace: t.TempDir(),
+ Title: "The run",
+ Brief: "drive the plan to the ground",
+ Slots: 1,
+ Limits: run.Limits{CostUSD: 5},
+ Factory: factory,
+ })
+ if outcome != run.OutcomeDone {
+ t.Fatalf("outcome = %q, want done", outcome)
+ }
+ if !strings.Contains(summary.Result, "fake's model said: all green") {
+ t.Fatalf("result = %q", summary.Result)
+ }
+ if summary.USD != 0.12 || summary.Steps != 2 || summary.Nodes != 1 {
+ t.Fatalf("summary = %+v", summary)
+ }
+ if root := store.Task(store.RootID()); root.Status != plandb.StatusDone {
+ t.Fatalf("root status = %q", root.Status)
+ }
+}
+
+// A run whose dollar ceiling the delegate's METERED calls cross is ended by
+// the run on the limit word, with the program terminated and its own terminal
+// kept — and the API refuses every call past the ceiling, so the run spent
+// exactly what the calls under it cost.
+func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) {
+ store := runOpenStore(t)
+ taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())
+ m, setup, calling, _ := realChild(t, 0.06, "6")
+ t.Setenv("FAKE_ENDING", "wait")
+ factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 0.10}, nil)
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store, Workspace: t.TempDir(), Slots: 1,
+ Limits: run.Limits{CostUSD: 0.10},
+ Factory: factory,
+ })
+ if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost {
+ t.Fatalf("outcome = %q limit = %q, want the cost limit", outcome, summary.Limit)
+ }
+ if len(summary.Cut) != 1 {
+ t.Fatalf("cut = %v, want the root cut by the run's own ending", summary.Cut)
+ }
+ if summary.USD != 0.12 || len(calling.seen()) != 2 {
+ t.Fatalf("usd %v after %d funnel calls, want exactly the two calls that crossed the ceiling", summary.USD, len(calling.seen()))
+ }
+ turns, _ := delegate.ReadTurns(taskDir, 0)
+ for _, turn := range turns[2:] {
+ if turn.Refused == "" || turn.CostUSD != 0 {
+ t.Fatalf("a call past the ceiling was made: %+v", turn)
+ }
+ }
+}
+
+// A PROGRAM REFUSED AT THE CEILING WAS STOPPED BY THE CEILING, however it says
+// it ended: the fake ends as `crashed` the moment a call is refused, the way
+// senior-dev does, and the task keeps the ceiling's words and not a crash's.
+func TestDelegateWorkerReportsAProgramRefusedAtTheCeilingAsTheCeiling(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup, calling, _ := realChild(t, 0.06, "5")
+ t.Setenv("FAKE_ENDING", "crash")
+ worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0.10, 0)
+ report, err := worker.Run(runContext(t), *store.Task(store.RootID()))
+ if err == nil || !strings.Contains(err.Error(), "fake reached the run's dollar ceiling of $0.10") || strings.Contains(err.Error(), "crashed") {
+ t.Fatalf("err = %v, want the ceiling named and no crash", err)
+ }
+ if report.USD != 0.12 || len(calling.seen()) != 2 {
+ t.Fatalf("usd %v after %d calls, want the two calls that reached the ceiling", report.USD, len(calling.seen()))
+ }
+ end := endLine(t, rawTrajectory(t, filepath.Dir(store.Path()), store.RootID()))
+ if !strings.HasPrefix(end.Reason, "fake reached the run's dollar ceiling") {
+ t.Fatalf("the trajectory ends %q", end.Reason)
+ }
+}
+
+// AND THE RUN ENDS ON THE PERSON'S COST LIMIT, whichever comes home first —
+// the supervisor's own stop or the program's ending after its refusal.
+func TestARunWhoseDelegateWasRefusedAtTheCeilingEndsOnTheCostLimit(t *testing.T) {
+ store := runOpenStore(t)
+ m, setup, _, _ := realChild(t, 0.06, "5")
+ t.Setenv("FAKE_ENDING", "crash")
+ factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 0.10}, nil)
+ outcome, summary := run.Start(runContext(t), run.Spec{
+ Store: store, Workspace: t.TempDir(), Slots: 1,
+ Limits: run.Limits{CostUSD: 0.10},
+ Factory: factory,
+ })
+ if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost || summary.USD != 0.12 {
+ t.Fatalf("outcome %q limit %q usd %v, want the cost limit at the two calls' 0.12", outcome, summary.Limit, summary.USD)
+ }
+}
+
+// TWO BUILDS, ONE RUN: a child that says another protocol version than this
+// build reads is stopped before it spends, and the reason names the fix.
+func TestDelegateWorkerStopsAChildOfAnotherBuild(t *testing.T) {
+ store := runOpenStore(t)
+ script := filepath.Join(t.TempDir(), "newer.sh")
+ program := "#!/bin/sh\n" + strings.Join([]string{
+ `echo '{"type":"hello","protocol":99,"delegate":"fake"}'`,
+ `trap 'exit 0' TERM`,
+ `sleep 30 &`,
+ `wait $!`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(script, []byte(program), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ worker := run.NewDelegateWorker(store, t.TempDir(), delegate.Delegate{Name: "fake", Default: "run"}, run.DelegateSetup{Exe: script, Grace: time.Second}, 0, 0)
+ started := time.Now()
+ _, err := worker.Run(runContext(t), *store.Task(store.RootID()))
+ if err == nil || !strings.Contains(err.Error(), "restart codeaf to run fake") || errors.Is(err, context.Canceled) {
+ t.Fatalf("err = %v, want the rebuild named and not the run's own ending", err)
+ }
+ if time.Since(started) > 10*time.Second {
+ t.Fatal("the mismatched child was not stopped")
+ }
+ if _, ok := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())); ok {
+ t.Fatal("a child of another build was written down as this run's program")
+ }
+}
diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go
index 6d8ca1b010..a17aace74b 100644
--- a/internal/run/enginewire.go
+++ b/internal/run/enginewire.go
@@ -23,36 +23,62 @@ import (
type engine struct{}
func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummary {
+ // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf
+ // that lands done is checked against its acceptance, and a check that
+ // does not hold becomes a fix task the run waits on.
+ limits := Limits{CostUSD: spec.CostUSD, Elapsed: spec.Elapsed, StepsPerTask: spec.StepsPerTask, ReviewRound: true}
+ // THE CREW IS THE PROFILE'S, read again at each launch, and the seat's
+ // provider is the door's own completer through the one seam a test
+ // scripts ([CrewFactory]).
+ //
+ // THE DOOR'S TWO SEATS RIDE WITH THE SPEC. The conversation resolved
+ // them itself (the enginewire spec's WorkModel and PlanModel), so the
+ // factory seats the work and plan roles on the door's answer rather than
+ // asking the profile again for a row the door already moved.
+ //
+ // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus
+ // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches
+ // a `/task` run the way the manual says it reaches a headless one.
+ factory := CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{
+ Work: spec.WorkModel,
+ Plan: spec.PlanModel,
+ Check: chatCheckSeat(),
+ }, spec.CompleterFor)
+ if spec.Delegate != nil {
+ // A DELEGATED RUN SEATS THE PROGRAM ON ITS ROOT and has no review
+ // round: a check seat is a bash-belt worker, which the belt switch may
+ // have left off, and the program's own verification is what its
+ // terminal record reports ([DelegateWorker]).
+ limits.ReviewRound = false
+ // AND ITS MODEL API RIDES THE CONVERSATION'S OWN ROAD: the completer the
+ // door handed the run, the services the conversation can reach, and the
+ // work seat a leaf of this run would sit on — which is where a call on
+ // a model nothing here can reach is answered instead.
+ setup := DelegateSetup{
+ CompleterFor: spec.CompleterFor,
+ Serves: spec.Serves,
+ Seat: WorkSeat(spec.ProfileDir, spec.WorkModel),
+ PlainFolder: spec.PlainFolder,
+ Crew: spec.Crew,
+ // AND ITS MONEY IS THE CONVERSATION'S, CALL BY CALL: every ledger row
+ // names the conversation and the task, and every call is folded
+ // into the conversation's books whole as it is metered.
+ Conversation: spec.Conversation,
+ OnCharge: spec.OnCharge,
+ }
+ factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory)
+ }
outcome, summary := Start(ctx, Spec{
Store: spec.Store,
Workspace: spec.Workspace,
Title: spec.Title,
Brief: spec.Brief,
Slots: spec.Slots,
- // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf
- // that lands done is checked against its acceptance, and a check that
- // does not hold becomes a fix task the run waits on.
- Limits: Limits{CostUSD: spec.CostUSD, Elapsed: spec.Elapsed, StepsPerTask: spec.StepsPerTask, ReviewRound: true},
- // THE CREW IS THE PROFILE'S, read again at each launch, and the seat's
- // provider is the door's own completer through the one seam a test
- // scripts ([CrewFactory]).
- //
- // THE DOOR'S TWO SEATS RIDE WITH THE SPEC. The conversation resolved
- // them itself (the enginewire spec's WorkModel and PlanModel), so the
- // factory seats the work and plan roles on the door's answer rather than
- // asking the profile again for a row the door already moved.
- //
- // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus
- // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches
- // a `/task` run the way the manual says it reaches a headless one.
- Factory: CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{
- Work: spec.WorkModel,
- Plan: spec.PlanModel,
- Check: chatCheckSeat(),
- }, spec.CompleterFor),
- OnSpend: spec.OnSpend,
- Gate: spec.Admission,
- OnHold: spec.OnHold,
+ Limits: limits,
+ Factory: factory,
+ OnSpend: spec.OnSpend,
+ Gate: spec.Admission,
+ OnHold: spec.OnHold,
})
return session.RunSummary{
Outcome: string(outcome),
@@ -62,6 +88,13 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar
// so the session draws the ending out of the fact and never parses the
// sentence back apart.
Limit: runLimitOf(summary.Limit),
+ // AND A PROGRAM'S OWN ENDING CROSSES AS ITSELF, the same way: its
+ // status word and its sentence, so the row names what the program said
+ // and not the run's one word for every unfinished ending.
+ Program: programEndingOf(summary.Program),
+ // AND THE WORD A PROGRAM FINISHED ON, which is how the session tells
+ // work its program checked from work nothing checked.
+ ProgramVerdict: summary.Verdict,
// THE ROWS THE RUN'S OWN ENDING CUT CROSS AS THEMSELVES: the same
// one-for-one carrying as the limit fact, so the session draws a row
// the person's bound took down from the run's own record of it and
@@ -100,6 +133,15 @@ func runLimitOf(limit Limit) session.RunLimit {
return ""
}
+// programEndingOf is the program's ending in the session's words, nil where no
+// program ended the run unfinished.
+func programEndingOf(ended *ProgramEndedError) *session.ProgramEnding {
+ if ended == nil {
+ return nil
+ }
+ return &session.ProgramEnding{Status: ended.Status, Reason: ended.Reason, Result: ended.Result}
+}
+
func (engine) Land(ctx context.Context, store *plandb.Store, workspace, base, rootID string) (session.RunLanding, error) {
landing, err := Land(ctx, store, workspace, base, rootID)
if err != nil {
diff --git a/internal/run/review_test.go b/internal/run/review_test.go
index 1f06e5e8e0..aa52505437 100644
--- a/internal/run/review_test.go
+++ b/internal/run/review_test.go
@@ -645,6 +645,10 @@ func TestSupervisorStillEndsIncompleteWhenRootErrorsWithoutStoredDone(t *testing
if root.Status == plandb.StatusDone || root.Result != "" {
t.Fatalf("root = %s with result %q, want no stored done or result", root.Status, root.Result)
}
+ // AND THE RUN IS OVER IN THE STORE: a failed run left open read as running.
+ if root.Status != plandb.StatusFailed || root.Error != "root worker failed" {
+ t.Fatalf("root = %s (%q), want failed with its worker's error", root.Status, root.Error)
+ }
}
func TestSupervisorAcceptsARootsReadingDoesNotHoldConclusion(t *testing.T) {
diff --git a/internal/run/run.go b/internal/run/run.go
index 4a9e5f119d..4b78fb89a4 100644
--- a/internal/run/run.go
+++ b/internal/run/run.go
@@ -162,6 +162,20 @@ type Supervisor struct {
steps int
rootResult string
rootFailed bool
+ // rootProgram is how the program a delegated run's root was handed to
+ // ended, when it ended without finishing ([ProgramEndedError]); nil for
+ // every other run.
+ rootProgram *ProgramEndedError
+ // rootVerdict is the program's own word for the work it finished
+ // ([Report.Verdict]); empty for every other run.
+ rootVerdict string
+ // rootFailure is the root worker's error when it failed, which the run's
+ // ending writes onto the root ([plandb.Store.FailRoot]).
+ rootFailure string
+ // rootCut says the root worker came home with a context's ending as its
+ // error: the run was cut, and its own task did not fail. The store is not
+ // ended for it ([Supervisor.pass] says why).
+ rootCut bool
// limitHit is which limit a person set ended this run, and empty while none
// has. It is set the moment the run decides a limit was reached (the
// elapsed signal in Run, the spend counters in countLiveSpend and
@@ -274,6 +288,7 @@ func (s *Supervisor) Run(ctx context.Context) Outcome {
s.steps = 0
s.rootResult = ""
s.rootFailed = false
+ s.rootCut = false
s.limitHit = ""
s.cut = make(map[string]bool)
s.dispatchedRoot = false
@@ -288,6 +303,13 @@ func (s *Supervisor) Run(ctx context.Context) Outcome {
if s.staleAfter <= 0 {
s.staleAfter = defaultStaleAfter
}
+ // A RUN HANDED NOTHING OF ITS DOLLAR LIMIT STARTS NO WORKER. The limit was
+ // spent before the run began ([Limits.costReached]), so the first pass
+ // launches nothing and answers the limit: no worker is seated to make the
+ // one paid call that would have told the loop so.
+ if s.limits.costReached(s.spent) {
+ s.limitHit = LimitCost
+ }
// TAKE-OVER BEFORE THE FIRST PASS: a claim a dead process left behind is
// released here, so the ready set the first pass reads can offer it again
// with no pass of waiting.
@@ -415,6 +437,24 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome {
}
if s.inFlight == 0 && (s.rootFailed || s.limitHit != "") {
+ if s.rootFailed && s.limitHit == "" && !s.rootCut && ctx.Err() == nil {
+ // THE RUN'S OWN TASK FAILED, SO THE RUN IS OVER, and the store says
+ // so: left open it read as running for ever, and a door that
+ // adopts open stores would take it up as live work
+ // ([plandb.Store.FailRoot]). A run a limit ended keeps its open
+ // work, which is what lets it be taken up again under a wider bound.
+ //
+ // AND A RUN THE CALLER CUT IS NOT A RUN THAT FAILED. When the
+ // caller's context ends, the root worker comes home with the
+ // context's own error, and that return and the context's end are
+ // both ready at the loop's select at once; Go picks either. Picked
+ // first, the return reached this line and wrote `context canceled`
+ // over the root as though the work had failed, on a run the caller's
+ // wall below deliberately leaves open for a later pass. Whichever
+ // the select picks, a cut root now ends the same way: incomplete,
+ // with the store as the run left it.
+ _ = s.store.FailRoot(s.rootFailure)
+ }
// Nothing of ours is running and the run cannot complete itself: the
// root's own worker failed, or the run has reached a limit a person set,
// in dollars or in time.
@@ -705,7 +745,8 @@ func (s *Supervisor) countLiveSpend() {
// after the time limit already ended the run does not rename that ending, and
// the workers are already ended by it.
func (s *Supervisor) reachCostLimit() {
- if s.limits.CostUSD <= 0 || s.spent < s.limits.CostUSD || s.limitHit != "" {
+ // A LIMIT WITH NOTHING LEFT IS REACHED WITH NOTHING SPENT ([Limits.costReached]).
+ if !s.limits.costReached(s.spent) || s.limitHit != "" {
return
}
s.limitHit = LimitCost
@@ -837,9 +878,19 @@ func (s *Supervisor) absorb(ret workerReturn) {
s.addReviewCheck(ret.task, root.Result)
} else {
s.rootFailed = true
+ s.rootFailure = ret.err.Error()
+ s.rootCut = errors.Is(ret.err, context.Canceled) || errors.Is(ret.err, context.DeadlineExceeded)
+ // A PROGRAM THAT ENDED WITHOUT FINISHING SAID WHY, and its words
+ // are the run's to carry, never to drop: the session draws the
+ // row out of them ([Summary.Program]).
+ var ended *ProgramEndedError
+ if errors.As(ret.err, &ended) {
+ s.rootProgram = ended
+ }
}
} else {
s.rootResult = ret.report.Result
+ s.rootVerdict = ret.report.Verdict
// THE CHILDLESS ROOT IS A LEAF, and it is checked like any other. If
// its worker already wrote the ending, the store preserves that result
// and moves the root back to waiting on the check; CompleteRoot writes
@@ -1721,6 +1772,14 @@ type Summary struct {
// run that did not end on one. The outcome word is the same sentence for
// both limits; this is what tells them apart.
Limit Limit
+ // Program is how a delegated run's program ended when it ended without
+ // finishing: its status word and its own account ([ProgramEndedError]).
+ // Nil for a run that finished, and for every run no program worked.
+ Program *ProgramEndedError
+ // Verdict is a delegated run's program's own word for the work it
+ // finished ([Report.Verdict]): senior-dev's `pass` or `pass-unverified`.
+ // Empty for every other run.
+ Verdict string
// Cut is every task the run's own ending cut mid-flight, by store id: its
// wall, its spend ceiling, or a person's stop ended the context their
// workers ran under. A task that failed on its own before the ending is
@@ -1830,6 +1889,8 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) {
Outcome: outcome,
Result: result,
Limit: supervisor.limitHit,
+ Program: supervisor.rootProgram,
+ Verdict: supervisor.rootVerdict,
Cut: supervisor.cutIDs(),
Nodes: supervisor.nodes,
Steps: supervisor.steps,
diff --git a/internal/run/stage_words_internal_test.go b/internal/run/stage_words_internal_test.go
new file mode 100644
index 0000000000..9e0b81b5d9
--- /dev/null
+++ b/internal/run/stage_words_internal_test.go
@@ -0,0 +1,100 @@
+package run
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+)
+
+// A PROGRAM'S STAGE IS SHOWN IN THE WORD IT GAVE A PERSON. senior-dev's page
+// read `agent-runtime` for the whole of its work, which is its machinery's name
+// for a model turn; the row now reads the program's own word for the phase, a
+// stage it gave no word keeps the word already shown, and a program that gave
+// no words at all is shown its stages as it spelled them.
+func TestAProgramsStageIsShownInTheWordItGaveAPerson(t *testing.T) {
+ live := func(program delegate.Delegate, stages ...[2]string) string {
+ t.Helper()
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "root", "root", "root")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ sink := &delegateSink{worker: &DelegateWorker{store: store, program: program}, taskID: "root", name: program.Name}
+ for _, stage := range stages {
+ sink.Stage(delegate.StageRecord{Stage: stage[0], Status: stage[1]})
+ }
+ return store.LiveSteps()["root"].Command
+ }
+ worded := delegate.Delegate{Name: "senior-dev", StageWords: map[string]string{"implement": "working"}}
+ if got := live(worded, [2]string{"implement", "running"}); got != "senior-dev: working" {
+ t.Fatalf("a worded stage reads %q, want the program's word and no status", got)
+ }
+ if got := live(worded, [2]string{"implement", "running"}, [2]string{"agent-runtime", "configured"}); got != "senior-dev: working" {
+ t.Fatalf("a stage with no word reads %q, want the word already shown to stand", got)
+ }
+ if got := live(delegate.Delegate{Name: "fake"}, [2]string{"implement", "running"}); got != "fake: implement · running" {
+ t.Fatalf("a program with no words reads %q, want its own stage and status", got)
+ }
+}
+
+// THE LIVE STEP FOLLOWS THE STEP OF THE PROGRAM'S PROCESS, AND EVERY RECORD IS
+// KEPT. Each stage, step and ending is written to the task's action log the
+// moment it arrives, stamped with codeaf's own clock; the row reads the word the
+// program's own reader gives the step a record served, falls back to the stage
+// words until a record has named one, and keeps the step's word through a stage
+// that names none.
+func TestTheLiveStepFollowsTheProgramsStepAndEveryRecordIsKept(t *testing.T) {
+ store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "root", "root", "root")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ program := delegate.Delegate{
+ Name: "senior-dev", StageWords: map[string]string{"intake": "reading the brief", "implement": "working"},
+ Present: func() delegate.ActionReader {
+ return func(action delegate.Action) (delegate.Shown, bool) {
+ if action.Kind == delegate.ActionStep {
+ return delegate.Shown{Step: action.Step, Text: action.Command}, true
+ }
+ return delegate.Shown{Text: action.Stage}, true
+ }
+ },
+ }
+ taskDir := t.TempDir()
+ sink := &delegateSink{worker: &DelegateWorker{store: store, program: program}, taskID: "root", taskDir: taskDir, storeDir: t.TempDir(), name: program.Name}
+ live := func() string { return store.LiveSteps()["root"].Command }
+
+ before := time.Now()
+ sink.Stage(delegate.StageRecord{Stage: "intake", Status: "captured"})
+ if got := live(); got != "senior-dev: reading the brief" {
+ t.Fatalf("before any step the row reads %q, want the stage's word", got)
+ }
+ exit := 1
+ sink.Step(delegate.StepRecord{Command: "bash: go test ./...", Tool: "bash", Step: "explore", Exit: &exit})
+ if got := live(); got != "senior-dev: explore" {
+ t.Fatalf("after a step the row reads %q, want the step's word", got)
+ }
+ sink.Stage(delegate.StageRecord{Stage: "implement", Status: "running"})
+ if got := live(); got != "senior-dev: explore" {
+ t.Fatalf("a stage that names no step moved the row to %q", got)
+ }
+ sink.Terminal(delegate.Terminal{Status: delegate.StatusPass, Message: "done"})
+
+ actions, err := delegate.ReadActions(taskDir, 0)
+ if err != nil || len(actions) != 4 {
+ t.Fatalf("the action log holds %+v (%v), want the four records", actions, err)
+ }
+ kinds := []string{actions[0].Kind, actions[1].Kind, actions[2].Kind, actions[3].Kind}
+ if strings.Join(kinds, ",") != "stage,step,stage,end" || actions[1].Exit == nil || *actions[1].Exit != 1 || actions[3].Message != "done" {
+ t.Fatalf("the action log = %+v", actions)
+ }
+ for i, action := range actions {
+ if action.At.Before(before) || (i > 0 && action.At.Before(actions[i-1].At)) {
+ t.Fatalf("action %d was stamped %v, want codeaf's own clock, in order", i, action.At)
+ }
+ }
+}
diff --git a/internal/run/testmain_test.go b/internal/run/testmain_test.go
index b402cd1519..3fdefd6ba4 100644
--- a/internal/run/testmain_test.go
+++ b/internal/run/testmain_test.go
@@ -7,7 +7,15 @@ import (
// TestMain keeps subprocess workers made by this suite out of the plan that
// launched go test. Tests that exercise the bound door set PLANDB_DB themselves.
+//
+// It is also the door a delegated run's REAL child comes in by: a test that
+// starts this very test binary as a program's process (delegate_child_test.go)
+// marks it in the environment, and the binary then runs the fake program's
+// body instead of the suite.
func TestMain(m *testing.M) {
+ if code, child := runAsDelegateChild(); child {
+ os.Exit(code)
+ }
_ = os.Unsetenv("PLANDB_DB")
_ = os.Unsetenv("PLANDB_RUN")
os.Exit(m.Run())
diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go
index 2a6ca87662..cc36e58596 100644
--- a/internal/run/trajectory.go
+++ b/internal/run/trajectory.go
@@ -16,6 +16,7 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
"unicode/utf8"
"github.com/Agent-Field/codeaf/internal/plandb"
@@ -102,6 +103,16 @@ type Step struct {
Result string `json:"result,omitempty"`
Reason string `json:"reason,omitempty"`
+ // StartedAt and EndedAt are a PROGRAM's own clock on the ending line of the
+ // task it was handed: the instant codeaf started its process and the
+ // instant that process was gone — the pair the program record carries
+ // (delegate.ProgramRecord). They are zero on every other line, on an ending
+ // written by a road that never started a process, and on every line a
+ // worker of this conversation's own wrote. Step lines never carry them, so
+ // the session's mirror of the step line (PlanStep) has no use for them.
+ StartedAt time.Time `json:"started_at,omitzero"`
+ EndedAt time.Time `json:"ended_at,omitzero"`
+
// ExitsRecorded is stamped true by a build that records each command's
// exit, on the OPENING line it writes before any step and on the ending
// line; bashworker.go sets it at both. A reader uses it to tell a record
diff --git a/internal/run/worker.go b/internal/run/worker.go
index 039a5a0abb..995fef5af0 100644
--- a/internal/run/worker.go
+++ b/internal/run/worker.go
@@ -27,6 +27,10 @@ type Report struct {
Steps int
USD float64
Waiting bool
+ // Verdict is a program's own word for the finished work it handed in —
+ // senior-dev's `pass` or `pass-unverified` — when a delegated run's program
+ // finished; empty for every other worker ([delegate.Terminal.Verdict]).
+ Verdict string
}
// Worker is one task's executor. The supervisor never talks to a model
@@ -80,6 +84,24 @@ type Limits struct {
ReviewRound bool
}
+// costDust is the most a dollar limit may still have left and be reached: a
+// billionth of a dollar, far below any call's price and far above the float
+// rounding in a sum of prices. The model API a program's calls go through
+// reads its ceiling the same way (internal/provider/modelapi's ceilingReached).
+const costDust = 1e-9
+
+// costReached reports whether a run's spend has reached its dollar limit.
+//
+// A LIMIT WITH NOTHING LEFT IS REACHED WITH NOTHING SPENT. The conversation
+// hands a run whose person's limit is already spent the smallest positive
+// figure, because zero means no limit at all; read as `spent >= limit`,
+// nothing spent was still under it, and a run whose program's first call was
+// refused for it ended as work that did not finish instead of on the limit the
+// person set.
+func (l Limits) costReached(spent float64) bool {
+ return l.CostUSD > 0 && l.CostUSD-spent <= costDust
+}
+
// stepsPerTaskKey is the type behind the context value, so a worker reads its
// cap with a typed lookup rather than a string key another package could
// collide with.
diff --git a/internal/seniordev/absentonwindows_test.go b/internal/seniordev/absentonwindows_test.go
new file mode 100644
index 0000000000..259c4bb4df
--- /dev/null
+++ b/internal/seniordev/absentonwindows_test.go
@@ -0,0 +1,53 @@
+//go:build !windows
+
+package seniordev
+
+import (
+ "go/build"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// SENIOR-DEV IS ABSENT ON WINDOWS, NOT BROKEN THERE. Its engine has never had
+// a Windows form of its process groups, file locks and bash shell, so no file
+// of it may reach a Windows build: the build's list is empty there
+// (internal/delegate/builtin/carried_windows.go), and this holds every Go file
+// under this tree, tests included, to a constraint that keeps it out. A file
+// that forgot one would put half an engine into a Windows build, where it
+// either fails to compile or compiles into something that fails every time.
+func TestNoFileOfSeniorDevReachesAWindowsBuild(t *testing.T) {
+ windows := build.Default
+ windows.GOOS, windows.GOARCH, windows.CgoEnabled = "windows", "amd64", false
+ checked := 0
+ err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() {
+ if entry.Name() == "testdata" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".go") {
+ return nil
+ }
+ checked++
+ included, err := windows.MatchFile(filepath.Dir(path), entry.Name())
+ if err != nil {
+ return err
+ }
+ if included {
+ t.Errorf("%s would be compiled into a Windows build; give it //go:build !windows", path)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if checked < 200 {
+ t.Fatalf("only %d files were checked; the walk has stopped seeing the tree", checked)
+ }
+}
diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go
new file mode 100644
index 0000000000..43ae3ccbf8
--- /dev/null
+++ b/internal/seniordev/actions.go
@@ -0,0 +1,421 @@
+//go:build !windows
+
+package seniordev
+
+// senior-dev's page, in senior-dev's words: every line of its action log — a
+// stage, a step, its ending — as what a person reads under the step of its
+// process it served (delegate.Delegate's Present). codeaf draws the page;
+// what senior-dev's records MEAN is said here, once, beside the words its
+// stages already have.
+//
+// THE PAGE SHOWS ONLY WHAT senior-dev REALLY DOES. It has no planner, no
+// reviewer and no subagent (baked/agents/coder.md): one model context works
+// through its spec, explores, pins a check, lists the requirements, implements
+// and hands in, in the order that model chooses; senior-dev then checks the
+// tree itself with the project's own build and tests and finishes. Around that
+// it compacts its model's memory, moves to another model, and steers its model
+// when it stops short — and each of those is a line here, said as senior-dev
+// steering its own work. Everything else it reports is machinery, and is left
+// out.
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/app"
+)
+
+// stepWords is each step of senior-dev's process (app.Steps) in the one word
+// its page prints at the head of the step and its task's row reads while it is
+// in it. Reading the spec is `spec`, because that is where the brief is kept.
+var stepWords = map[string]string{
+ app.StepBrief: "spec",
+ app.StepExplore: "explore",
+ app.StepPin: "pin",
+ app.StepChecklist: "checklist",
+ app.StepImplement: "implement",
+ app.StepSubmit: "submit",
+ app.StepVerify: "verify",
+}
+
+// The two words for what senior-dev does around its model context with no
+// model at all: setting up the folder it works in, and finishing — putting
+// back the tree it stands by and measuring the change.
+const (
+ setupWord = "setup"
+ finishWord = "finish"
+)
+
+// presentActions is senior-dev's reader of its own action log: one per log,
+// told every line in the order it was written.
+//
+// IT REMEMBERS ONE THING, THE HIGHEST ATTEMPT SO FAR. senior-dev reports
+// `implement · running` with its attempt each time it starts its model on a
+// turn: attempt 0 at the start, one more after each nudge, and the SAME attempt
+// again when it retries a dropped call or corrects a malformed one. Only a
+// higher attempt than any before is a nudge.
+func presentActions() delegate.ActionReader {
+ attempt := 0
+ return func(action delegate.Action) (delegate.Shown, bool) {
+ if action.Kind == delegate.ActionStage && action.Stage == "implement" && action.Status == "running" {
+ facts := stageFactsOf(action.Data)
+ if next := facts.whole("attempt"); next > attempt {
+ attempt = next
+ return nudged(next), true
+ }
+ return delegate.Shown{}, false
+ }
+ return presentAction(action)
+ }
+}
+
+// nudged is senior-dev telling its model, which stopped without handing in,
+// what it found about the tree and to finish (solo.go's nudge).
+func nudged(attempt int) delegate.Shown {
+ return delegate.Shown{
+ Step: stepWords[app.StepImplement], Steer: true,
+ Text: fmt.Sprintf("told its model what it found, and to finish and hand in (nudge %d)", attempt),
+ }
+}
+
+// presentAction is one line of the log in senior-dev's words, for every line
+// that needs nothing before it to be read.
+func presentAction(action delegate.Action) (delegate.Shown, bool) {
+ switch action.Kind {
+ case delegate.ActionStep:
+ return presentStep(action)
+ case delegate.ActionStage:
+ return presentStage(action.Stage, action.Status, stageFactsOf(action.Data))
+ case delegate.ActionEnd:
+ // THE ENDING IS senior-dev's OWN SENTENCE — what its check of the
+ // project found — under the step that finishes the run.
+ return delegate.Shown{Step: finishWord, Text: strings.TrimSpace(action.Message)}, true
+ }
+ return delegate.Shown{}, false
+}
+
+// presentStep is one finished tool call: the verb a person would use for it,
+// what it was aimed at, and for a command how it came out.
+func presentStep(action delegate.Action) (delegate.Shown, bool) {
+ tool := strings.TrimSpace(action.Tool)
+ about := strings.TrimSpace(action.Command)
+ if tool != "" {
+ about = strings.TrimSpace(strings.TrimPrefix(about, tool+":"))
+ }
+ shown := delegate.Shown{Step: stepWords[action.Step]}
+ own := ownRecord(action.Step)
+ switch tool {
+ case "submit":
+ // The hand-in's own stage says whether it was taken and what it held.
+ return delegate.Shown{}, false
+ case "bash":
+ if action.Step == app.StepVerify {
+ // senior-dev's own check: the command is the whole of what it did.
+ shown.Text = about
+ } else {
+ shown.Text = "ran " + about
+ }
+ shown.Outcome = delegate.ExitWord(action.Exit)
+ if action.Exit == nil {
+ shown.Outcome = "did not finish"
+ }
+ case "read":
+ shown.Text = "read " + firstOf(own, about)
+ case "write":
+ switch action.Step {
+ case app.StepPin:
+ shown.Text = "pinned its check"
+ case app.StepChecklist:
+ shown.Text = "wrote its checklist"
+ default:
+ shown.Text = "wrote " + firstOf(own, about)
+ }
+ case "edit", "apply_patch":
+ switch action.Step {
+ case app.StepPin:
+ shown.Text = "changed its pinned check"
+ case app.StepChecklist:
+ shown.Text = "updated its checklist"
+ default:
+ shown.Text = "edited " + firstOf(own, patchedFile(tool, about))
+ }
+ case "grep":
+ shown.Text = "searched " + about
+ case "glob":
+ shown.Text = "listed " + about
+ case "webfetch":
+ shown.Text = "fetched " + about
+ case "websearch":
+ shown.Text = "searched the web for " + about
+ case "question":
+ shown.Text = "asked a question, with nobody there to answer it"
+ default:
+ shown.Text = strings.TrimSpace(action.Command)
+ }
+ shown.Detail = stepDetail(action)
+ // A CHANGE TO THE WORK WEARS ITS LINES, `+N,-M`, the way git counts them.
+ // senior-dev's own records — its spec, pinned check and checklist — are
+ // its bookkeeping, not the work, and wear none.
+ if (tool == "write" || tool == "edit" || tool == "apply_patch") && own == "" && action.Added != nil && action.Removed != nil {
+ shown.Lines, shown.Added, shown.Removed = true, *action.Added, *action.Removed
+ }
+ return shown, strings.TrimSpace(shown.Text) != ""
+}
+
+// stepDetail is the whole of one step as the log kept it: the command or
+// argument the tool was called with, and what came back, for the page to open
+// under the step's one line.
+func stepDetail(action delegate.Action) string {
+ var parts []string
+ if command := strings.TrimSpace(action.Command); command != "" {
+ parts = append(parts, command)
+ }
+ if observation := strings.TrimRight(action.Observation, " \n\t"); strings.TrimSpace(observation) != "" {
+ parts = append(parts, observation)
+ }
+ return strings.Join(parts, "\n\n")
+}
+
+// ownRecord is how the page names one of senior-dev's own records when an
+// action served its step, and "" for every other step.
+func ownRecord(step string) string {
+ switch step {
+ case app.StepBrief:
+ return "its spec"
+ case app.StepPin:
+ return "its pinned check"
+ case app.StepChecklist:
+ return "its checklist"
+ }
+ return ""
+}
+
+// patchedFile is the first file a patch names, from its own header, when the
+// action was a patch; anything else is what it was about already.
+func patchedFile(tool, about string) string {
+ if tool != "apply_patch" {
+ return about
+ }
+ for _, header := range []string{"*** Update File: ", "*** Add File: ", "*** Delete File: "} {
+ if at := strings.Index(about, header); at >= 0 {
+ name := strings.TrimSpace(about[at+len(header):])
+ if end := strings.Index(name, " "); end > 0 {
+ name = name[:end]
+ }
+ if name != "" {
+ return name
+ }
+ }
+ }
+ return "its files"
+}
+
+// presentStage is one stage record. A stage that is only machinery — the
+// run's contract, a model turn being configured, a withdrawn call, the usage
+// rollup — is left out; so is one another line already says (the hand-in's
+// `implement · submitted`, the check's `landing · checked`, a tree left as it
+// was). `implement · running` is the reader's own ([presentActions]).
+func presentStage(stage, status string, facts stageFacts) (delegate.Shown, bool) {
+ switch stage + "/" + status {
+ case "bootstrap/ready":
+ // How it keeps its record of the tree: in git, or — in a folder with no
+ // git history, `--in-place` — in checkpoints of its own outside it.
+ outcome := facts.text("recorder")
+ if outcome == "snapshot" {
+ outcome = "no git history"
+ }
+ return delegate.Shown{Step: setupWord, Text: "set up its workspace", Outcome: outcome}, true
+ case "intake/captured":
+ return delegate.Shown{Step: stepWords[app.StepBrief], Text: "wrote your brief down as its spec"}, true
+
+ case "implement/transport-retry":
+ text := "the call to its model dropped; started a fresh turn"
+ if retry, most := facts.whole("retry"), facts.whole("max_retries"); retry > 0 && most > 0 {
+ text += fmt.Sprintf(" (retry %d of %d)", retry, most)
+ }
+ return delegate.Shown{Text: text, Steer: true}, true
+ case "implement/tool-call-leak":
+ return delegate.Shown{Text: "its model wrote a tool call as text; told it to call the tool", Steer: true}, true
+ case "implement/turn-error":
+ text := "its model's turn failed"
+ if why := facts.text("error"); why != "" {
+ text += ": " + why
+ }
+ return delegate.Shown{Text: text}, true
+ case "implement/unsubmitted":
+ return delegate.Shown{Text: "stopped without handing in its work"}, true
+
+ case "compaction-capacity/pinned":
+ text := "learned how much its model can hold"
+ if limit := facts.whole("limit_tokens"); limit > 0 {
+ text = fmt.Sprintf("learned its model holds %s tokens", thousands(limit))
+ }
+ return delegate.Shown{Text: text}, true
+ case "compaction/summarized", "compaction/fallback":
+ shown := delegate.Shown{Text: "compacted its memory", Memory: true}
+ if status == "fallback" {
+ shown.Outcome = "kept its own record"
+ }
+ return shown, true
+ case "model-switch/switched":
+ to := facts.text("to")
+ if to == "" {
+ return delegate.Shown{}, false
+ }
+ return delegate.Shown{Text: "switched to " + modelWord(to), Model: to, Reason: switchReason(facts.text("reason"))}, true
+
+ case "submit/frozen":
+ var outcome []string
+ if files := facts.whole("patch_files"); files > 0 {
+ outcome = append(outcome, plural(files, "file", "files"))
+ }
+ if items := facts.whole("checklist_items"); items > 0 {
+ outcome = append(outcome, fmt.Sprintf("%d of %d ticked", facts.whole("checklist_ticked"), items))
+ }
+ return delegate.Shown{Step: stepWords[app.StepSubmit], Text: "handed in its work", Outcome: strings.Join(outcome, " · ")}, true
+ case "submit/refused":
+ return delegate.Shown{Step: stepWords[app.StepSubmit], Text: "its hand-in was refused", Outcome: refusalWord(facts.text("reason_class"))}, true
+
+ case "verification/running":
+ return delegate.Shown{Step: stepWords[app.StepVerify], Text: "checked its work itself, with the project's own build and tests"}, true
+ case "verification/pass", "verification/fail":
+ shown := delegate.Shown{Step: stepWords[app.StepVerify]}
+ switch {
+ case facts.yes("vacuous"):
+ shown.Text = "found no build or tests to run"
+ case status == "pass":
+ shown.Text = "the project's own build and tests pass"
+ default:
+ shown.Text = "the project's own build or tests fail"
+ }
+ if commands := facts.whole("commands"); commands > 0 {
+ shown.Outcome = plural(commands, "command", "commands")
+ }
+ return shown, true
+ case "landing/repair-turn":
+ return delegate.Shown{Step: stepWords[app.StepImplement], Steer: true, Text: "time is short: gave its model one last turn to finish"}, true
+ case "landing/restored":
+ return delegate.Shown{Step: finishWord, Text: "put the tree back to " + app.RestoredFrom(facts.text("source"))}, true
+ case "landing/restore-failed":
+ return delegate.Shown{Step: finishWord, Text: "could not put the tree back"}, true
+ case "ship/restored":
+ return delegate.Shown{Step: finishWord, Text: "put back the work it handed in, which had changed since"}, true
+ case "ship/restore-failed":
+ return delegate.Shown{Step: finishWord, Text: "could not put back the work it handed in"}, true
+ case "patch-summary/completed":
+ files := facts.whole("files")
+ if files < 1 {
+ return delegate.Shown{Step: finishWord, Text: "its change is empty"}, true
+ }
+ outcome := plural(files, "file", "files")
+ if facts.has("additions") || facts.has("deletions") {
+ outcome += fmt.Sprintf(" · +%d -%d", facts.whole("additions"), facts.whole("deletions"))
+ }
+ return delegate.Shown{Step: finishWord, Text: "measured its change", Outcome: outcome}, true
+ }
+ return delegate.Shown{}, false
+}
+
+// refusalWord is a refused hand-in's reason, in a person's words.
+func refusalWord(class string) string {
+ switch class {
+ case "already-submitted":
+ return "it had already handed in"
+ case "empty-tree":
+ return "nothing had changed"
+ case "no-checklist":
+ return "it had no checklist"
+ case "capture-error", "record-error":
+ return "the tree could not be recorded"
+ }
+ return ""
+}
+
+// switchReason is why the router moved the coder, in a person's words, or ""
+// when the move was the router's ordinary choice.
+func switchReason(reason string) string {
+ reason = strings.TrimPrefix(reason, "constraint-relaxed:")
+ switch reason {
+ case "previous-cooling":
+ return "the last one kept failing"
+ case "previous-rate-limited":
+ return "the last one was rate-limited"
+ case "previous-busy":
+ return "the last one was busy"
+ case "better-score":
+ return "it was doing better"
+ case "all-cooling":
+ return "every model was failing"
+ }
+ return ""
+}
+
+// modelWord is a model id as a line names it: the part after the last vendor.
+func modelWord(id string) string {
+ id = strings.TrimSpace(id)
+ if at := strings.LastIndexByte(id, '/'); at >= 0 && at+1 < len(id) {
+ return id[at+1:]
+ }
+ return id
+}
+
+func firstOf(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func plural(n int, one, many string) string {
+ if n == 1 {
+ return "1 " + one
+ }
+ return fmt.Sprintf("%d %s", n, many)
+}
+
+// thousands writes a count with its thousands grouped, the way a person reads a
+// window size.
+func thousands(n int) string {
+ digits := fmt.Sprint(n)
+ var out strings.Builder
+ for i, digit := range digits {
+ if i > 0 && (len(digits)-i)%3 == 0 {
+ out.WriteByte(',')
+ }
+ out.WriteRune(digit)
+ }
+ return out.String()
+}
+
+// stageFacts is a stage record's data, read forgivingly: a key that is absent
+// or of another shape reads as nothing.
+type stageFacts map[string]any
+
+func stageFactsOf(raw json.RawMessage) stageFacts {
+ var facts stageFacts
+ if len(raw) == 0 || json.Unmarshal(raw, &facts) != nil {
+ return nil
+ }
+ return facts
+}
+
+func (f stageFacts) has(key string) bool { _, ok := f[key]; return ok }
+
+func (f stageFacts) text(key string) string {
+ text, _ := f[key].(string)
+ return strings.TrimSpace(text)
+}
+
+func (f stageFacts) whole(key string) int {
+ n, _ := f[key].(float64)
+ return int(n)
+}
+
+func (f stageFacts) yes(key string) bool {
+ yes, _ := f[key].(bool)
+ return yes
+}
diff --git a/internal/seniordev/actions_test.go b/internal/seniordev/actions_test.go
new file mode 100644
index 0000000000..feffd7ad58
--- /dev/null
+++ b/internal/seniordev/actions_test.go
@@ -0,0 +1,196 @@
+//go:build !windows
+
+package seniordev
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/app"
+)
+
+// EVERY STEP OF senior-dev's PROCESS HAS ONE WORD ITS PAGE PRINTS, and every
+// word is one plain lowercase word with no machinery in it — the same word leads
+// its task's row while it is in that step.
+func TestEveryStepHasOnePlainWord(t *testing.T) {
+ banned := []string{"auditor", "audit", "verdict", "verified", "refuted", "runtime", "contract", "router"}
+ words := map[string]bool{setupWord: true, finishWord: true}
+ for _, step := range app.Steps {
+ word := stepWords[step]
+ if word == "" {
+ t.Errorf("step %q has no word", step)
+ }
+ words[word] = true
+ }
+ for word := range words {
+ if word != strings.ToLower(word) || strings.ContainsAny(word, " \t") {
+ t.Errorf("step word %q is not one lowercase word", word)
+ }
+ for _, bad := range banned {
+ if strings.Contains(word, bad) {
+ t.Errorf("step word %q says %q", word, bad)
+ }
+ }
+ }
+}
+
+// actionLog is a run as senior-dev reports it, each record received a second
+// after the one before.
+func actionLog(records ...delegate.Action) []delegate.Action {
+ start := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC)
+ for i := range records {
+ records[i].At = start.Add(time.Duration(i) * time.Second)
+ }
+ return records
+}
+
+func stage(name, status string, data map[string]any) delegate.Action {
+ raw, _ := json.Marshal(data)
+ if data == nil {
+ raw = nil
+ }
+ return delegate.Action{Kind: delegate.ActionStage, Stage: name, Status: status, Data: raw}
+}
+
+func step(tool, step, command string, exit ...int) delegate.Action {
+ action := delegate.Action{Kind: delegate.ActionStep, Tool: tool, Step: step, Command: tool + ": " + command}
+ if len(exit) > 0 {
+ action.Exit = &exit[0]
+ }
+ return action
+}
+
+// A RUN READS AS senior-dev's ACTIONS, EACH UNDER THE STEP OF ITS PROCESS IT
+// SERVED: set up, the brief written down as its spec, what it read and ran and
+// changed, the hand-in with its size and ticks, its own check command by
+// command and the result, what it did to the tree and the change it measured,
+// and its ending in its own sentence. Machinery — the run's contract, a model
+// turn being configured, the usage rollup, the submit call itself — is left
+// out; a nudge is senior-dev steering its model, and a retry of the same
+// attempt is not a second nudge.
+func TestARunReadsAsSeniorDevsActionsUnderItsSteps(t *testing.T) {
+ log := actionLog(
+ stage("bootstrap", "ready", map[string]any{"recorder": "git"}),
+ stage("run-contract", "ready", nil),
+ stage("intake", "captured", map[string]any{"spec_bytes": 42}),
+ stage("landing", "start-captured", nil),
+ stage("implement", "running", map[string]any{"attempt": 0}),
+ stage("agent-runtime", "configured", nil),
+ step("read", app.StepBrief, "/copy/.senior-dev/spec.md"),
+ step("read", app.StepExplore, "internal/auth/middleware.go"),
+ step("bash", app.StepExplore, "go test ./internal/auth/...", 1),
+ step("write", app.StepPin, ".senior-dev/pinned.txt"),
+ step("write", app.StepChecklist, ".senior-dev/checklist.md"),
+ step("edit", app.StepImplement, "internal/auth/middleware.go"),
+ stage("compaction", "summarized", map[string]any{"summary_status": "valid"}),
+ stage("model-switch", "switched", map[string]any{"from": "openrouter/vendor/one", "to": "openrouter/vendor/two", "reason": "previous-rate-limited"}),
+ stage("implement", "running", map[string]any{"attempt": 1}),
+ stage("implement", "transport-retry", map[string]any{"attempt": 1, "retry": 1, "max_retries": 3}),
+ stage("implement", "running", map[string]any{"attempt": 1}),
+ step("bash", app.StepImplement, "go test ./internal/auth/...", 0),
+ step("submit", app.StepSubmit, "tests pass"),
+ stage("submit", "frozen", map[string]any{"patch_files": 4, "checklist_items": 5, "checklist_ticked": 5}),
+ stage("implement", "submitted", nil),
+ stage("verification", "running", map[string]any{"commands": 2}),
+ step("bash", app.StepVerify, "go build ./...", 0),
+ step("bash", app.StepVerify, "go test ./...", 2),
+ stage("verification", "fail", map[string]any{"commands": 2}),
+ stage("ship", "unchanged", nil),
+ stage("patch-summary", "completed", map[string]any{"files": 4, "additions": 120, "deletions": 30}),
+ stage("agent-summary", "completed", nil),
+ delegate.Action{Kind: delegate.ActionEnd, Status: delegate.StatusFail, Message: "submitted a change that the project's own build or tests do not pass"},
+ )
+ read := Program.Reader()
+ type line struct{ step, text, outcome string }
+ var got []line
+ steers := 0
+ for _, action := range log {
+ shown, ok := read(action)
+ if !ok {
+ continue
+ }
+ if !shown.At.Equal(action.At) {
+ t.Fatalf("%q lost its moment: %v, want %v", shown.Text, shown.At, action.At)
+ }
+ if shown.Steer {
+ steers++
+ }
+ got = append(got, line{shown.Step, shown.Text, shown.Outcome})
+ }
+ want := []line{
+ {"setup", "set up its workspace", "git"},
+ {"spec", "wrote your brief down as its spec", ""},
+ {"spec", "read its spec", ""},
+ {"explore", "read internal/auth/middleware.go", ""},
+ {"explore", "ran go test ./internal/auth/...", "fails · exit 1"},
+ {"pin", "pinned its check", ""},
+ {"checklist", "wrote its checklist", ""},
+ {"implement", "edited internal/auth/middleware.go", ""},
+ {"", "compacted its memory", ""},
+ {"", "switched to two", ""},
+ {"implement", "told its model what it found, and to finish and hand in (nudge 1)", ""},
+ {"", "the call to its model dropped; started a fresh turn (retry 1 of 3)", ""},
+ {"implement", "ran go test ./internal/auth/...", "passes"},
+ {"submit", "handed in its work", "4 files · 5 of 5 ticked"},
+ {"verify", "checked its work itself, with the project's own build and tests", ""},
+ {"verify", "go build ./...", "passes"},
+ {"verify", "go test ./...", "fails · exit 2"},
+ {"verify", "the project's own build or tests fail", "2 commands"},
+ {"finish", "measured its change", "4 files · +120 -30"},
+ {"finish", "submitted a change that the project's own build or tests do not pass", ""},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("read %d lines, want %d:\n%+v", len(got), len(want), got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("line %d = %+v, want %+v", i, got[i], want[i])
+ }
+ }
+ if steers != 2 {
+ t.Fatalf("%d lines are senior-dev steering its model, want the nudge and the retry", steers)
+ }
+}
+
+// A SWITCH NAMES THE MODEL AND WHY, and a compaction whose summary failed says
+// the run kept its own record instead.
+func TestASwitchAndACompactionSayWhatHappened(t *testing.T) {
+ read := Program.Reader()
+ switched, ok := read(stage("model-switch", "switched", map[string]any{"to": "openrouter/vendor/two", "reason": "previous-rate-limited"}))
+ if !ok || switched.Model != "openrouter/vendor/two" || switched.Reason != "the last one was rate-limited" {
+ t.Fatalf("the switch read %+v", switched)
+ }
+ fallback, ok := read(stage("compaction", "fallback", nil))
+ if !ok || !fallback.Memory || fallback.Outcome != "kept its own record" {
+ t.Fatalf("the fallback compaction read %+v", fallback)
+ }
+}
+
+// A CHANGE TO THE WORK WEARS ITS LINES, and senior-dev's own records do not:
+// an implement step's edit counts `+N,-M`, the checklist it writes counts
+// nothing, a step whose program counted nothing wears nothing, and every step
+// keeps its whole self for the page to open.
+func TestAChangeToTheWorkWearsItsLinesAndItsOwnRecordsDoNot(t *testing.T) {
+ counted := func(action delegate.Action, added, removed int) delegate.Action {
+ action.Added, action.Removed = &added, &removed
+ action.Observation = "Edit applied successfully."
+ return action
+ }
+ read := Program.Reader()
+ edit, _ := read(counted(step("edit", "implement", "internal/auth/middleware.go"), 12, 3))
+ if !edit.Lines || edit.Added != 12 || edit.Removed != 3 {
+ t.Fatalf("an implement edit = %+v, want +12,-3", edit)
+ }
+ if edit.Detail != "edit: internal/auth/middleware.go\n\nEdit applied successfully." {
+ t.Fatalf("the edit's whole step = %q", edit.Detail)
+ }
+ if own, _ := read(counted(step("write", "checklist", ".senior-dev/checklist.md"), 9, 0)); own.Lines {
+ t.Fatalf("its own checklist wears lines: %+v", own)
+ }
+ if bare, _ := read(step("write", "implement", "a.go")); bare.Lines {
+ t.Fatalf("a write nobody counted wears lines: %+v", bare)
+ }
+}
diff --git a/internal/seniordev/app/args.go b/internal/seniordev/app/args.go
new file mode 100644
index 0000000000..9cd72c4f5b
--- /dev/null
+++ b/internal/seniordev/app/args.go
@@ -0,0 +1,90 @@
+//go:build !windows
+
+package app
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+)
+
+// DefaultHighModels is the pool the coder routes on when the command line
+// names none: `--high` on `codeaf senior-dev run`. Each entry is a model on the
+// service codeaf's model API speaks for, and senior-dev's own router picks
+// among them call by call (internal/seniordev/router/adaptive); codeaf's funnel
+// then serves the call the router picked.
+const DefaultHighModels = "openrouter/deepseek/deepseek-v4-flash-0731,openrouter/deepseek/deepseek-v4-pro,openrouter/qwen/qwen3.6-plus,openrouter/moonshotai/kimi-k2.6,openrouter/z-ai/glm-5.1,openrouter/minimax/minimax-m2.7"
+
+// cliArgs is what one run was asked to do, as the command line said it: the
+// run command's own flags (internal/seniordev) plus the ceilings codeaf hands
+// every program it carries. senior-dev's own parser, its `--format`, `--tui`
+// and help were codeaf's to replace, and are gone; this is what the run itself
+// reads.
+type cliArgs struct {
+ High string
+ Low string
+ Frontier string
+ // Variant is sent as `reasoning.effort`. Empty sends no `reasoning` key,
+ // so the service's own default applies.
+ Variant string
+ // InPlace forces the snapshot recorder: senior-dev edits the workspace
+ // without writing to any repository around it. Without it, the snapshot
+ // recorder is still chosen wherever there is no git history to use.
+ InPlace bool
+ MaxCost *float64
+ MaxHours *float64
+}
+
+// CrewModel is a crew seat's model as a pool entry: the id filed under the
+// service codeaf's model API speaks for, which is how every pool entry is
+// spelled ([DefaultHighModels]). An id already filed there is left alone.
+func CrewModel(id string) string {
+ id = strings.TrimSpace(id)
+ if id == "" || strings.HasPrefix(id, orclient.Service+"/") {
+ return id
+ }
+ return orclient.Service + "/" + id
+}
+
+// crewPools keeps, of pools a conversation's crew filled, only the models the
+// catalog can size — a call on one it cannot is a call senior-dev refuses to
+// make — and says which it dropped. A --high with nothing left routes on
+// [DefaultHighModels], because a crew of models this catalog does not know is
+// no reason to stop a run codeaf already started; an empty --low or
+// --frontier falls back to --high, as it always does.
+//
+// IT IS ONLY FOR A CREW. A person who types --high at a shell meant those
+// models, and is told plainly when one cannot be served; a crew was chosen for
+// the conversation, and a program that cannot use one of its seats uses its
+// own list rather than failing an hour of work.
+func crewPools(args cliArgs, known func(string) bool, notes io.Writer) cliArgs {
+ keep := func(raw string) string {
+ var kept []string
+ for _, ref := range splitPool(raw) {
+ if known(ref) {
+ kept = append(kept, ref)
+ continue
+ }
+ _, _ = fmt.Fprintf(notes, "[senior-dev] the crew's %s is not in the model catalog; it is left out of this run\n", ref)
+ }
+ return strings.Join(kept, ",")
+ }
+ args.High, args.Low, args.Frontier = keep(args.High), keep(args.Low), keep(args.Frontier)
+ if args.High == "" {
+ _, _ = fmt.Fprintf(notes, "[senior-dev] none of the crew's models can be sized; routing on senior-dev's own list\n")
+ args.High = DefaultHighModels
+ }
+ return args
+}
+
+func splitPool(raw string) []string {
+ out := []string{}
+ for _, value := range strings.Split(raw, ",") {
+ if value = strings.TrimSpace(value); value != "" {
+ out = append(out, value)
+ }
+ }
+ return out
+}
diff --git a/internal/seniordev/app/budget_helpers.go b/internal/seniordev/app/budget_helpers.go
new file mode 100644
index 0000000000..20dadc884c
--- /dev/null
+++ b/internal/seniordev/app/budget_helpers.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package app
+
+// Budget queries for the solo loop. The run's ceilings are --max-cost and
+// --max-hours.
+
+// budgetIsExhausted is the boolean-only form of budgetExhausted, for callers
+// that do not need the reason string.
+func (runner *pipeline) budgetIsExhausted() bool {
+ exhausted, _ := runner.budgetExhausted()
+ return exhausted
+}
diff --git a/internal/seniordev/app/catalog_test.go b/internal/seniordev/app/catalog_test.go
new file mode 100644
index 0000000000..bbe4fbed07
--- /dev/null
+++ b/internal/seniordev/app/catalog_test.go
@@ -0,0 +1,75 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev"
+)
+
+func seniorDevCatalogFixture(t *testing.T) modelsdev.Catalog {
+ t.Helper()
+ client, err := modelsdev.New(modelsdev.Options{
+ CatalogPath: "../modelsdev/testdata/catalog.json",
+ CacheDir: t.TempDir(),
+ DisableFetch: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ catalog, err := client.Get(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return catalog
+}
+
+func TestSeniorDevCatalogMetadataReachesSessionModel(t *testing.T) {
+ models := seniorDevModels{
+ backend: &modelAPIBackend{catalog: seniorDevCatalogFixture(t)},
+ sessionID: "ses_catalog",
+ agent: "coder",
+ }
+ resolved, err := models.Resolve(context.Background(), msgmodel.User{
+ Model: msgmodel.UserModel{
+ ProviderID: "openrouter",
+ ModelID: "fixture/vendor-model",
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.Calc.Cost == nil || resolved.Calc.Cost.Input != 1.25 ||
+ resolved.Calc.Cost.Output != 4.5 || resolved.Calc.Limit.Context != 240_000 ||
+ resolved.Calc.Limit.Input == nil || *resolved.Calc.Limit.Input != 220_000 ||
+ resolved.Calc.Limit.Output != 12_000 || resolved.Request.MaxOutputTokens == nil ||
+ *resolved.Request.MaxOutputTokens != 12_000 {
+ t.Fatalf("resolved catalog model = %#v", resolved)
+ }
+ projection, _, err := models.projection("openrouter", "fixture/vendor-model")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !projection.Capabilities.Temperature || !projection.Capabilities.Reasoning ||
+ !projection.Capabilities.Attachment || !projection.Capabilities.ToolCall ||
+ !projection.Capabilities.Input["text"] || !projection.Capabilities.Input["image"] ||
+ projection.Capabilities.Input["audio"] || !projection.Capabilities.Output["text"] {
+ t.Fatalf("engine capability projection = %#v", projection.Capabilities)
+ }
+
+ // OpenRouter ids are split at the provider prefix before the exact catalog
+ // key lookup, matching Provider.parseModel/splitModel.
+ if _, err := models.GetModel(
+ context.Background(), "", "openrouter/fixture/vendor-model",
+ ); err != nil {
+ t.Fatalf("normalized OpenRouter id: %v", err)
+ }
+ if _, err := models.GetModel(
+ context.Background(), "openrouter", "fixture/unknown",
+ ); err == nil {
+ t.Fatal("unknown catalog model unexpectedly resolved")
+ }
+}
diff --git a/internal/seniordev/app/compaction_events.go b/internal/seniordev/app/compaction_events.go
new file mode 100644
index 0000000000..2d1b50030e
--- /dev/null
+++ b/internal/seniordev/app/compaction_events.go
@@ -0,0 +1,61 @@
+//go:build !windows
+
+package app
+
+import (
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+)
+
+var seniorDevCompactionDecisionEvent = bus.Define(
+ "session.compaction.decision", compaction.CompactionDecision{},
+)
+
+// seniorDevCompactionDecisionSink hears every compaction the run makes: it
+// publishes the decision on the instance bus, as it always has, and reports it
+// as the `compaction` stage, so codeaf's page can say the run compacted its
+// memory and whether its model summarized it or the deterministic record stood
+// in.
+//
+// THE STAGE IS A REPORT OF A DECISION ALREADY MADE. The sink is told after the
+// history has been rewritten; nothing the model sees and nothing about when or
+// how the run compacts depends on it.
+type seniorDevCompactionDecisionSink struct {
+ bus *bus.Bus
+ events *eventWriter
+}
+
+func newSeniorDevCompactionDecisionSink(instance *bus.Bus, events *eventWriter) compaction.DecisionSink {
+ if instance == nil && events == nil {
+ return nil
+ }
+ return seniorDevCompactionDecisionSink{bus: instance, events: events}
+}
+
+func (sink seniorDevCompactionDecisionSink) CompactionDecision(
+ decision compaction.CompactionDecision,
+) {
+ if sink.bus != nil {
+ sink.bus.Publish(seniorDevCompactionDecisionEvent, decision)
+ }
+ if sink.events != nil {
+ sink.events.stage("compaction", compactionStatus(decision.SummaryStatus), map[string]any{
+ "summary_status": decision.SummaryStatus,
+ "before_tokens": decision.Before, "after_tokens": decision.After,
+ })
+ }
+}
+
+// compactionStatus is the compaction stage's status: `summarized` when the
+// model's summary stands in the history (valid, or normalized into shape), and
+// `fallback` for every other ending, each of which installs the deterministic
+// record in its place (compaction's CompactionDecision.SummaryStatus).
+func compactionStatus(summary string) string {
+ switch summary {
+ case "valid", "normalized":
+ return "summarized"
+ }
+ return "fallback"
+}
+
+var _ compaction.DecisionSink = seniorDevCompactionDecisionSink{}
diff --git a/internal/seniordev/app/compaction_pin.go b/internal/seniordev/app/compaction_pin.go
new file mode 100644
index 0000000000..498a8bb43b
--- /dev/null
+++ b/internal/seniordev/app/compaction_pin.go
@@ -0,0 +1,192 @@
+//go:build !windows
+
+package app
+
+import (
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+)
+
+// The compaction budget is the model's advertised window (capped by
+// capacity_tokens). When a request is routed to an endpoint that serves a
+// smaller window than the catalog advertises, the provider rejects it and the
+// step loop compacts (ResultCompact). That already recovers the run; what it
+// does not do is remember the smaller limit, so the context grows back toward
+// the window and can be rejected again on the same route. A pin records the
+// limit the provider named, for the rest of the session, as a capacity_tokens
+// minimum.
+//
+// Only a limit stated in the rejection text is pinned. A rejection that names
+// no number is recorded as such and pins nothing: the compaction still
+// happens, and a pin that cannot be justified from the error would be a
+// silent behaviour change.
+
+// overflowLimitPatterns are the context-overflow messages (see
+// retrysched.contextOverflowPatterns) that carry the endpoint's limit.
+var overflowLimitPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)maximum context length is (\d+) tokens`),
+ regexp.MustCompile(`(?i)maximum prompt length is (\d+)`),
+ regexp.MustCompile(`(?i)context length is only (\d+) tokens`),
+ regexp.MustCompile(`(?i)exceeds the limit of (\d+)`),
+ regexp.MustCompile(`(?i)too large for model with (\d+) maximum context length`),
+}
+
+// parseContextLimit extracts the limit an overflow rejection names.
+func parseContextLimit(text string) (float64, bool) {
+ for _, pattern := range overflowLimitPatterns {
+ if match := pattern.FindStringSubmatch(text); match != nil {
+ if value, err := strconv.ParseFloat(match[1], 64); err == nil && value > 0 {
+ return value, true
+ }
+ }
+ }
+ return 0, false
+}
+
+// overflowText joins every text a classified provider error carries: the
+// message and, when present, the response body the provider sent.
+func overflowText(err error) string {
+ classified := retrysched.FromError(err)
+ parts := []string{err.Error()}
+ for _, extra := range []*string{classified.Data.Message, classified.Data.ResponseBody} {
+ if extra == nil || *extra == "" {
+ continue
+ }
+ duplicate := false
+ for _, have := range parts {
+ if strings.Contains(have, *extra) || strings.Contains(*extra, have) {
+ duplicate = true
+ break
+ }
+ }
+ if !duplicate {
+ parts = append(parts, *extra)
+ }
+ }
+ return strings.Join(parts, "\n")
+}
+
+// pinnedCapacityFor is the session's pinned capacity, if a rejection set one.
+func (backend *modelAPIBackend) pinnedCapacityFor(sessionID string) (float64, bool) {
+ if backend == nil {
+ return 0, false
+ }
+ backend.pinMu.Lock()
+ defer backend.pinMu.Unlock()
+ value, ok := backend.pinnedCapacity[sessionID]
+ return value, ok
+}
+
+// overflowConfigFor is the compaction config a session runs under: the project
+// config, with a pinned capacity folded in as a minimum.
+func (backend *modelAPIBackend) overflowConfigFor(sessionID string) (overflow.Config, error) {
+ cfg, err := backend.config.overflowConfig()
+ if err != nil {
+ return cfg, err
+ }
+ return backend.withPinnedCapacity(cfg, sessionID), nil
+}
+
+// withPinnedCapacity folds the session's pin into a compaction config as a
+// capacity_tokens minimum. Unpinned sessions get cfg back as is.
+func (backend *modelAPIBackend) withPinnedCapacity(cfg overflow.Config, sessionID string) overflow.Config {
+ pinned, ok := backend.pinnedCapacityFor(sessionID)
+ if !ok {
+ return cfg
+ }
+ block := overflow.CompactionConfig{}
+ if cfg.Compaction != nil {
+ block = *cfg.Compaction
+ }
+ if block.CapacityTokens == nil || *block.CapacityTokens > pinned {
+ block.CapacityTokens = &pinned
+ }
+ cfg.Compaction = &block
+ return cfg
+}
+
+// pinCapacityOnOverflow inspects a failed request. A context-overflow
+// rejection that names a limit pins the session's capacity to that limit
+// minus the output reservation; one that does not is recorded and pins
+// nothing. Every path emits an event, so a pinned run is visible in the
+// stream.
+func (backend *modelAPIBackend) pinCapacityOnOverflow(
+ sessionID, agent, providerID, modelID string, err error,
+) {
+ if backend == nil || err == nil {
+ return
+ }
+ cfg, cfgErr := backend.config.overflowConfig()
+ if cfgErr != nil {
+ return
+ }
+ if !retrysched.IsContextOverflow(retrysched.FromError(err)) {
+ return
+ }
+ text := overflowText(err)
+ excerpt := text
+ if len(excerpt) > 240 {
+ excerpt = excerpt[:240]
+ }
+ data := map[string]any{
+ "agent": agent, "session_id": sessionID,
+ "provider_id": providerID, "model_id": modelID,
+ "message": excerpt,
+ }
+ limit, ok := parseContextLimit(text)
+ if !ok {
+ data["source"] = "unparsed"
+ backend.emitStage("compaction-capacity", "overflow-unpinned", data)
+ return
+ }
+ reservation := float64(0)
+ if _, model, projErr := (seniorDevModels{backend: backend, agent: agent}).projection(providerID, modelID); projErr == nil {
+ reservation = calc.MaxOutputTokens(model)
+ }
+ if cfg.Compaction != nil && cfg.Compaction.Reserved != nil {
+ reservation = *cfg.Compaction.Reserved
+ }
+ pinned := math.Floor(limit - reservation)
+ if pinned <= 0 || math.IsNaN(pinned) || math.IsInf(pinned, 0) {
+ data["source"] = "unparsed"
+ data["limit_tokens"] = limit
+ data["reason"] = "the named limit leaves no input space after the output reservation"
+ backend.emitStage("compaction-capacity", "overflow-unpinned", data)
+ return
+ }
+ backend.pinMu.Lock()
+ if backend.pinnedCapacity == nil {
+ backend.pinnedCapacity = map[string]float64{}
+ }
+ if previous, exists := backend.pinnedCapacity[sessionID]; exists && previous < pinned {
+ pinned = previous
+ }
+ backend.pinnedCapacity[sessionID] = pinned
+ backend.pinMu.Unlock()
+ data["source"] = "parsed"
+ data["limit_tokens"] = limit
+ data["reservation_tokens"] = reservation
+ data["pinned_capacity_tokens"] = pinned
+ if pinnedCfg, err := backend.overflowConfigFor(sessionID); err == nil {
+ if _, model, projErr := (seniorDevModels{backend: backend, agent: agent}).projection(providerID, modelID); projErr == nil {
+ marks := overflow.Watermarks(overflow.UsableInput{Cfg: pinnedCfg, Model: model})
+ data["capacity_tokens"] = marks.Capacity
+ data["high_tokens"] = marks.High
+ data["low_tokens"] = marks.Low
+ }
+ }
+ backend.emitStage("compaction-capacity", "pinned", data)
+}
+
+func (backend *modelAPIBackend) emitStage(stage, status string, data map[string]any) {
+ if backend == nil || backend.events == nil {
+ return
+ }
+ backend.events.stage(stage, status, data)
+}
diff --git a/internal/seniordev/app/compaction_pin_test.go b/internal/seniordev/app/compaction_pin_test.go
new file mode 100644
index 0000000000..6a9f5cb46c
--- /dev/null
+++ b/internal/seniordev/app/compaction_pin_test.go
@@ -0,0 +1,163 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched"
+)
+
+func TestParseContextLimitReadsTheNumberedOverflowMessages(t *testing.T) {
+ for text, want := range map[string]float64{
+ "This endpoint's maximum context length is 262144 tokens. However, you requested about 301000 tokens": 262144,
+ "maximum prompt length is 131072": 131072,
+ "context length is only 200000 tokens": 200000,
+ "input exceeds the limit of 128000": 128000,
+ "prompt too large for model with 65536 maximum context length": 65536,
+ } {
+ if got, ok := parseContextLimit(text); !ok || got != want {
+ t.Errorf("%q → %v,%v want %v", text, got, ok, want)
+ }
+ }
+ for _, text := range []string{"prompt is too long", "context_length_exceeded", "400 (no body)", ""} {
+ if _, ok := parseContextLimit(text); ok {
+ t.Errorf("%q should not parse a limit", text)
+ }
+ }
+}
+
+// overflowBackend is a backend whose transport rejects every request with the
+// given body, so DoStream returns the provider error the pin logic inspects.
+func overflowBackend(t *testing.T, info configpkg.Info, status int, body string) (*modelAPIBackend, *bytes.Buffer) {
+ t.Helper()
+ cfg, err := newSeniorDevConfig(info)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var events bytes.Buffer
+ backend := &modelAPIBackend{
+ api: testModelAPI, catalog: seniorDevCatalogFixture(t), config: cfg,
+ events: newEventWriter(&events),
+ client: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ return recordedResponse(request, status, "application/json", body), nil
+ })},
+ }
+ return backend, &events
+}
+
+func overflowStream(t *testing.T, backend *modelAPIBackend, session string) error {
+ t.Helper()
+ projection, _, err := (seniorDevModels{backend: backend, agent: "coder"}).projection("openrouter", "fixture/vendor-model")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := seniorDevStreamClient{
+ backend: backend, sessionID: session, agent: "coder", model: projection,
+ client: &orclient.Client{
+ BaseURL: backend.api.BaseURL, Fetcher: backend.fetch,
+ Compatibility: orclient.CompatibilityCompatible,
+ },
+ }
+ _, err = client.DoStream(context.Background(), orclient.RequestParams{
+ ModelID: "fixture/vendor-model",
+ Prompt: []msgmodel.ModelMessage{{Role: "user", Content: "hello"}},
+ })
+ return err
+}
+
+const overflowBody = `{"error":{"message":"This endpoint's maximum context length is 131072 tokens. However, you requested about 150000 tokens (150000 of text input). Please reduce the length of either one.","code":400}}`
+
+func TestContextOverflowPinsCapacityUnderTheWindowPolicy(t *testing.T) {
+ backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 400, overflowBody)
+
+ err := overflowStream(t, backend, "ses-pin")
+ if err == nil || !retrysched.IsContextOverflow(retrysched.FromError(err)) {
+ t.Fatalf("DoStream error = %v, want a context-overflow rejection passed through", err)
+ }
+ // Fixture output limit 12,000 is the reservation: 131,072 − 12,000. The
+ // fixture's own input window (220,000 − 12,000 = 208,000) is wider, so
+ // the pin is what tightens.
+ pinned, ok := backend.pinnedCapacityFor("ses-pin")
+ if !ok || pinned != 119_072 {
+ t.Fatalf("pinned capacity = %v,%v want 119072", pinned, ok)
+ }
+ if _, ok := backend.pinnedCapacityFor("ses-other"); ok {
+ t.Fatal("a pin must be per session")
+ }
+ got := events.String()
+ for _, want := range []string{
+ `"stage":"compaction-capacity"`, `"status":"pinned"`, `"source":"parsed"`,
+ `"limit_tokens":131072`, `"reservation_tokens":12000`, `"pinned_capacity_tokens":119072`,
+ `"capacity_tokens":119072`, `"high_tokens":71443`, `"low_tokens":47628`,
+ `"session_id":"ses-pin"`, `"model_id":"fixture/vendor-model"`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("pinned event lacks %s: %s", want, got)
+ }
+ }
+ // The session's compaction config now carries the pin as capacity_tokens,
+ // and the project config is untouched for other sessions.
+ cfg, err := backend.overflowConfigFor("ses-pin")
+ if err != nil || cfg.Compaction.CapacityTokens == nil || *cfg.Compaction.CapacityTokens != 119_072 {
+ t.Fatalf("session config = %+v, %v", cfg.Compaction, err)
+ }
+ other, _ := backend.overflowConfigFor("ses-other")
+ if other.Compaction.CapacityTokens != nil {
+ t.Fatalf("other session inherited the pin: %+v", other.Compaction)
+ }
+ // A later rejection naming a larger limit never raises the pin.
+ backend.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ return recordedResponse(request, 400, "application/json", `{"error":{"message":"maximum context length is 262144 tokens","code":400}}`), nil
+ })}
+ _ = overflowStream(t, backend, "ses-pin")
+ if pinned, _ := backend.pinnedCapacityFor("ses-pin"); pinned != 119_072 {
+ t.Fatalf("pin was raised to %v", pinned)
+ }
+ // And the configured event for a later turn in that session records it.
+ var provenance bytes.Buffer
+ runtime := &runtimeAdapter{config: backend.config, backend: backend, events: newEventWriter(&provenance)}
+ if _, err := runtime.configureTurn(turn{Agent: "coder", SessionID: "ses-pin", AgentMarkdown: "p", ProviderID: "openrouter", ModelID: "fixture/vendor-model"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := provenance.String(); !strings.Contains(got, `"pinned_capacity_tokens":119072`) || !strings.Contains(got, `"high_tokens":71443`) {
+ t.Fatalf("configured event does not carry the pin: %s", got)
+ }
+}
+
+func TestContextOverflowWithoutANumberPinsNothing(t *testing.T) {
+ backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 400,
+ `{"error":{"message":"prompt is too long for this model","code":400}}`)
+ if err := overflowStream(t, backend, "ses-unparsed"); err == nil {
+ t.Fatal("expected the rejection to pass through")
+ }
+ if _, ok := backend.pinnedCapacityFor("ses-unparsed"); ok {
+ t.Fatal("an unparsed rejection must not pin")
+ }
+ got := events.String()
+ if !strings.Contains(got, `"status":"overflow-unpinned"`) || !strings.Contains(got, `"source":"unparsed"`) {
+ t.Fatalf("unparsed rejection not recorded: %s", got)
+ }
+ cfg, _ := backend.overflowConfigFor("ses-unparsed")
+ if cfg.Compaction.CapacityTokens != nil {
+ t.Fatalf("config changed without a pin: %+v", cfg.Compaction)
+ }
+}
+
+func TestNonOverflowErrorsDoNotPin(t *testing.T) {
+ backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 429,
+ `{"error":{"message":"rate limited","code":429}}`)
+ if err := overflowStream(t, backend, "ses-429"); err == nil {
+ t.Fatal("expected the error to pass through")
+ }
+ if _, ok := backend.pinnedCapacityFor("ses-429"); ok || strings.Contains(events.String(), "compaction-capacity") {
+ t.Fatalf("a non-overflow error pinned or emitted: %s", events.String())
+ }
+}
diff --git a/internal/seniordev/app/compaction_policy_test.go b/internal/seniordev/app/compaction_policy_test.go
new file mode 100644
index 0000000000..814dd8865f
--- /dev/null
+++ b/internal/seniordev/app/compaction_policy_test.go
@@ -0,0 +1,106 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config"
+)
+
+// A malformed compaction block must fail the config load, not the first turn.
+func TestCompactionPolicyIsValidatedAtConfigLoad(t *testing.T) {
+ for name, block := range map[string]map[string]any{
+ "unknown policy": {"policy": "adaptive"},
+ "legacy policy": {"policy": "legacy"},
+ "fraction of one": {"policy": "window", "preserve_recent_fraction": 1},
+ "negative fraction": {"policy": "window", "preserve_recent_fraction": -0.2},
+ "zero capacity": {"policy": "window", "capacity_tokens": 0},
+ "non-numeric capacity": {"policy": "window", "capacity_tokens": "lots"},
+ } {
+ _, err := newSeniorDevConfig(configpkg.Info{"compaction": block})
+ if err == nil || !strings.Contains(err.Error(), "compaction") {
+ t.Errorf("%s: newSeniorDevConfig error = %v, want a compaction config failure", name, err)
+ }
+ }
+ for name, block := range map[string]map[string]any{
+ "window by name": {"policy": "window", "capacity_tokens": 500000, "preserve_recent_fraction": 0.2},
+ "window by absence": {"preserve_recent_tokens": 60000},
+ } {
+ if _, err := newSeniorDevConfig(configpkg.Info{"compaction": block}); err != nil {
+ t.Errorf("%s: newSeniorDevConfig error = %v, want none", name, err)
+ }
+ }
+ if _, err := newSeniorDevConfig(configpkg.Info{}); err != nil {
+ t.Errorf("no compaction block: %v", err)
+ }
+}
+
+// The configured event carries the compaction budget the turn runs under, so
+// the budget a run used can be read back from the stream alone.
+func TestConfiguredTurnProvenanceCarriesCompactionBudget(t *testing.T) {
+ emit := func(t *testing.T, info configpkg.Info, backend backend) map[string]any {
+ t.Helper()
+ cfg, err := newSeniorDevConfig(info)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var output bytes.Buffer
+ runtime := &runtimeAdapter{config: cfg, backend: backend, events: newEventWriter(&output)}
+ if _, err := runtime.configureTurn(turn{
+ Agent: "coder", SessionID: "ses-compaction", AgentMarkdown: "prompt",
+ ProviderID: "openrouter", ModelID: "fixture/vendor-model",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
+ var event map[string]any
+ if err := json.Unmarshal([]byte(line), &event); err != nil {
+ t.Fatalf("event line %q: %v", line, err)
+ }
+ if event["stage"] == "agent-runtime" && event["status"] == "configured" {
+ data := event["data"].(map[string]any)
+ record, ok := data["compaction"].(map[string]any)
+ if !ok {
+ t.Fatalf("configured event carries no compaction record: %s", line)
+ }
+ return record
+ }
+ }
+ t.Fatal("no configured event emitted")
+ return nil
+ }
+ // Fixture model: context 240,000, input 220,000, output 12,000. The
+ // reservation is min(12,000, 32,000) = 12,000, so raw = 208,000.
+ fixture := &modelAPIBackend{catalog: seniorDevCatalogFixture(t)}
+
+ t.Run("no backend records no budget", func(t *testing.T) {
+ record := emit(t, configpkg.Info{}, nil)
+ if record["capacity_tokens"] != nil {
+ t.Fatalf("record = %v", record)
+ }
+ })
+ t.Run("the default budgets from the window under the default cap", func(t *testing.T) {
+ record := emit(t, configpkg.Info{}, fixture)
+ // 208,000 is under the 500,000 default cap; high 124,800; low
+ // 83,200; tail 0.2 x high = 24,960.
+ if record["capacity_tokens"] != 208_000.0 ||
+ record["high_tokens"] != 124_800.0 || record["low_tokens"] != 83_200.0 ||
+ record["tail_budget_tokens"] != 24_960.0 || record["model_context_tokens"] != 240_000.0 {
+ t.Fatalf("default record = %v", record)
+ }
+ })
+ t.Run("a configured cap records both the cap and its effect", func(t *testing.T) {
+ record := emit(t, configpkg.Info{"compaction": map[string]any{
+ "policy": "window", "capacity_tokens": 100000, "preserve_recent_fraction": 0.1,
+ }}, fixture)
+ if record["configured_capacity_tokens"] != 100_000.0 || record["configured_preserve_recent_fraction"] != 0.1 ||
+ record["capacity_tokens"] != 100_000.0 || record["high_tokens"] != 60_000.0 ||
+ record["low_tokens"] != 40_000.0 || record["tail_budget_tokens"] != 6_000.0 {
+ t.Fatalf("capped record = %v", record)
+ }
+ })
+}
diff --git a/internal/seniordev/app/config.go b/internal/seniordev/app/config.go
new file mode 100644
index 0000000000..0a1f77dfe2
--- /dev/null
+++ b/internal/seniordev/app/config.go
@@ -0,0 +1,431 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/permission"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+type seniorDevConfig struct {
+ info configpkg.Info
+ service *configpkg.Service
+ global permission.Ruleset
+ agentRules map[string]permission.Ruleset
+ bakedRules map[string]permission.Ruleset
+ // variant is the run-level reasoning effort from --variant, applied to a
+ // turn that names none so the configured event records what is sent.
+ variant string
+}
+
+func loadSeniorDevConfig(workspace string) (*seniorDevConfig, error) {
+ env := configpkg.NewEnv(os.LookupEnv)
+ globalDir, _ := env.Get("SENIOR_DEV_CONFIG_DIR")
+ service := configpkg.NewService(configpkg.Loader{GlobalDir: globalDir, Env: env})
+ info, err := service.Get(workspace, workspace)
+ if err != nil {
+ return nil, err
+ }
+ result, err := newSeniorDevConfig(info)
+ if result != nil {
+ result.service = service
+ }
+ return result, err
+}
+
+func newSeniorDevConfig(info configpkg.Info) (*seniorDevConfig, error) {
+ result := &seniorDevConfig{
+ info: info, agentRules: map[string]permission.Ruleset{},
+ bakedRules: map[string]permission.Ruleset{},
+ }
+ var err error
+ result.global, err = configPermissionRules(info["permission"])
+ if err != nil {
+ return nil, fmt.Errorf("permission config: %w", err)
+ }
+ if err := refuseRetiredModelKnobs(info); err != nil {
+ return nil, err
+ }
+ // The compaction block is parsed once here so a malformed block fails the
+ // load instead of the first turn.
+ if ovf, err := result.overflowConfig(); err != nil {
+ return nil, fmt.Errorf("compaction config: %w", err)
+ } else if err := overflow.ValidatePolicy(ovf); err != nil {
+ return nil, fmt.Errorf("compaction config: %w", err)
+ }
+ for _, name := range baked.ListBakedAgents() {
+ markdown, _ := baked.GetBakedAgentMarkdown(name)
+ rules, parseErr := permission.RulesetFromFrontmatter(markdown)
+ if parseErr != nil {
+ return nil, fmt.Errorf("baked agent %q permissions: %w", name, parseErr)
+ }
+ result.bakedRules[name] = rules
+ }
+ for name, raw := range objectValue(info["agent"]) {
+ agent := objectValue(raw)
+ rules := toolPermissionRules(agent["tools"])
+ configured, parseErr := configPermissionRules(agent["permission"])
+ if parseErr != nil {
+ return nil, fmt.Errorf("agent %q permission config: %w", name, parseErr)
+ }
+ result.agentRules[name] = permission.Merge(rules, configured)
+ }
+ return result, nil
+}
+
+func configPermissionRules(value any) (permission.Ruleset, error) {
+ if value == nil {
+ return nil, nil
+ }
+ switch value.(type) {
+ case *configpkg.OrderedObject, string:
+ default:
+ return nil, fmt.Errorf("permission object did not preserve source order")
+ }
+ data, err := json.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ parsed, err := permission.ParseConfigJSON(data)
+ if err != nil {
+ return nil, err
+ }
+ return permission.FromConfig(parsed), nil
+}
+
+func toolPermissionRules(settings any) permission.Ruleset {
+ entries := []configpkg.OrderedEntry{}
+ if ordered, ok := settings.(*configpkg.OrderedObject); ok {
+ entries = ordered.Entries()
+ } else {
+ mapping := objectValue(settings)
+ keys := make([]string, 0, len(mapping))
+ for name := range mapping {
+ keys = append(keys, name)
+ }
+ sort.Strings(keys)
+ for _, name := range keys {
+ entries = append(entries, configpkg.OrderedEntry{Key: name, Value: mapping[name]})
+ }
+ }
+ rules := make(permission.Ruleset, 0, len(entries))
+ for _, entry := range entries {
+ name := entry.Key
+ enabled, ok := entry.Value.(bool)
+ if !ok {
+ continue
+ }
+ permissionName := name
+ if name == "write" || name == "edit" || name == "patch" || name == "apply_patch" {
+ permissionName = "edit"
+ }
+ action := permission.ActionDeny
+ if enabled {
+ action = permission.ActionAllow
+ }
+ rules = append(rules, permission.Rule{
+ Permission: permissionName, Pattern: "*", Action: action,
+ })
+ }
+ return rules
+}
+
+func (cfg *seniorDevConfig) rulesForAgent(name string) permission.Ruleset {
+ if cfg == nil {
+ return nil
+ }
+ return permission.Merge(cfg.bakedRules[name], cfg.global, cfg.agentRules[name])
+}
+
+func (cfg *seniorDevConfig) registryOptions() tool.RegistryOptions {
+ if cfg == nil {
+ return tool.RegistryOptions{}
+ }
+ return tool.RegistryOptions{
+ Instructions: cfg.instructions(),
+ Config: cfg.service,
+ AllowExternalDirectories: true,
+ // Reads may leave the workspace; writes may not (tool/path.go's
+ // resolveWritePath says why).
+ ConfineWrites: true,
+ PermissionRules: func(_ context.Context, call steploop.ToolCall) permission.Ruleset {
+ return cfg.rulesForAgent(call.Agent)
+ },
+ }
+}
+
+func (cfg *seniorDevConfig) instructions() []string {
+ if cfg == nil {
+ return nil
+ }
+ values, _ := cfg.info["instructions"].([]any)
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ if text, ok := value.(string); ok {
+ out = append(out, text)
+ }
+ }
+ return out
+}
+
+func (cfg *seniorDevConfig) agent(name string) map[string]any {
+ if cfg == nil {
+ return nil
+ }
+ return objectValue(objectValue(cfg.info["agent"])[name])
+}
+
+func (cfg *seniorDevConfig) overflowConfig() (overflow.Config, error) {
+ if cfg == nil {
+ return overflow.Config{}, nil
+ }
+ raw, err := json.Marshal(map[string]any{
+ "compaction": cfg.info["compaction"],
+ })
+ if err != nil {
+ return overflow.Config{}, err
+ }
+ var result overflow.Config
+ if err := json.Unmarshal(raw, &result); err != nil {
+ return overflow.Config{}, err
+ }
+ return result, nil
+}
+
+func (cfg *seniorDevConfig) configureTurn(value turn) (turn, error) {
+ // Baked frontmatter is an executable agent contract, not model-visible
+ // decoration. Apply its deterministic controls first. An explicitly chosen
+ // pool model remains authoritative; a baked model is only a default when the
+ // caller supplied no model. Project config below has final precedence.
+ //
+ // A baked agent's temperature is deliberately not applied: no generation
+ // parameter is sent, so the provider's own default applies.
+ value = applyBakedTurnControls(value)
+ if cfg != nil && value.Variant == "" {
+ value.Variant = cfg.variant
+ }
+ return applyConfiguredTurnControls(value, cfg.agent(value.Agent))
+}
+
+// applyBakedTurnControls applies the deterministic controls in a baked
+// agent's frontmatter. The other frontmatter control, `tier:`, is read by
+// baked.TierFor at call time: it selects the router pool, not a turn field.
+func applyBakedTurnControls(value turn) turn {
+ metadata, ok := baked.GetBakedAgentMetadata(value.Agent)
+ if !ok {
+ return value
+ }
+ if value.ProviderID == "" && value.ModelID == "" {
+ if model, ok := metadata["model"].(string); ok && model != "" && model != "inherit" {
+ value.ProviderID, value.ModelID = splitConfiguredModel(model)
+ }
+ }
+ if steps, ok := configNumber(metadata["steps"]); ok && steps > 0 {
+ value.MaxSteps = &steps
+ } else if steps, ok := configNumber(metadata["maxSteps"]); ok && steps > 0 {
+ value.MaxSteps = &steps
+ }
+ return value
+}
+
+func applyConfiguredTurnControls(value turn, agent map[string]any) (turn, error) {
+ if disabled, _ := agent["disable"].(bool); disabled {
+ return value, fmt.Errorf("agent %q is disabled by config", value.Agent)
+ }
+ if prompt, ok := agent["prompt"].(string); ok {
+ value.AgentMarkdown = prompt
+ value.AgentPromptVerbatim = true
+ }
+ if model, ok := agent["model"].(string); ok && model != "" && model != "inherit" {
+ value.ProviderID, value.ModelID = splitConfiguredModel(model)
+ }
+ if variant, ok := agent["variant"].(string); ok {
+ value.Variant = variant
+ }
+ if steps, ok := configNumber(agent["steps"]); ok && steps > 0 {
+ value.MaxSteps = &steps
+ } else if steps, ok := configNumber(agent["maxSteps"]); ok && steps > 0 {
+ value.MaxSteps = &steps
+ }
+ return value, nil
+}
+
+func (cfg *seniorDevConfig) disabledTools(agent string, ids []string) map[string]bool {
+ out := map[string]bool{}
+ if cfg == nil {
+ return out
+ }
+ for _, name := range permission.Disabled(ids, cfg.rulesForAgent(agent)).Values() {
+ out[name] = true
+ }
+ return out
+}
+
+func (cfg *seniorDevConfig) options(agent, providerID, modelID string) *orclient.Object {
+ result := orclient.NewObject()
+ if cfg == nil {
+ return result
+ }
+ model := cfg.model(providerID, modelID)
+ for _, source := range []map[string]any{objectValue(model["options"]), objectValue(cfg.agent(agent)["options"])} {
+ data, err := json.Marshal(source)
+ if err != nil {
+ continue
+ }
+ parsed, err := orclient.ParseObject(data)
+ if err == nil {
+ result = orclient.MergeOptions(result, parsed)
+ }
+ }
+ return result
+}
+
+// refuseRetiredModelKnobs refuses, by name, the three config keys that used
+// to decide how senior-dev reached a model and no longer can: a service's
+// `apiKey` and `baseURL`, and any `providerRouting` block.
+//
+// A REMOVED KNOB FAILS LOUDLY, which is senior-dev's rule for every knob it
+// retires. It reaches a model only through the model API codeaf serves the
+// run, which holds the key, the address and the routing itself; a config that
+// still set them and was quietly ignored would label a run with a behaviour it
+// did not have, and an apiKey or baseURL honoured would be a second road to a
+// model that codeaf could not meter, cap or show.
+func refuseRetiredModelKnobs(info configpkg.Info) error {
+ for providerID, rawProvider := range objectValue(info["provider"]) {
+ provider := objectValue(rawProvider)
+ options := objectValue(provider["options"])
+ for _, key := range []string{"apiKey", "baseURL"} {
+ if _, set := options[key]; set && providerID == orclient.Service {
+ return fmt.Errorf("provider %q options.%s is not read: senior-dev reaches a model only through the model API codeaf serves it — remove the key", providerID, key)
+ }
+ }
+ if provider["providerRouting"] != nil {
+ return fmt.Errorf("provider %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", providerID)
+ }
+ for modelID, rawModel := range objectValue(provider["models"]) {
+ if objectValue(rawModel)["providerRouting"] != nil {
+ return fmt.Errorf("provider %q model %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", providerID, modelID)
+ }
+ }
+ }
+ for name, raw := range objectValue(info["agent"]) {
+ if objectValue(raw)["providerRouting"] != nil {
+ return fmt.Errorf("agent %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", name)
+ }
+ }
+ return nil
+}
+
+func (cfg *seniorDevConfig) provider(providerID string) map[string]any {
+ if cfg == nil {
+ return nil
+ }
+ return objectValue(objectValue(cfg.info["provider"])[providerID])
+}
+
+func (cfg *seniorDevConfig) model(providerID, modelID string) map[string]any {
+ provider := cfg.provider(providerID)
+ return objectValue(objectValue(provider["models"])[modelID])
+}
+
+func (cfg *seniorDevConfig) headers(providerID, modelID string) []orclient.HeaderPair {
+ if cfg == nil {
+ return nil
+ }
+ values := map[string]string{}
+ for _, source := range []map[string]any{
+ objectValue(cfg.provider(providerID)["options"]),
+ cfg.model(providerID, modelID),
+ } {
+ for name, raw := range objectValue(source["headers"]) {
+ if value, ok := raw.(string); ok {
+ values[name] = value
+ }
+ }
+ }
+ names := make([]string, 0, len(values))
+ for name := range values {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ result := make([]orclient.HeaderPair, 0, len(names))
+ for _, name := range names {
+ result = append(result, orclient.HeaderPair{Name: name, Value: values[name]})
+ }
+ return result
+}
+
+func (cfg *seniorDevConfig) applyBackend(backend *modelAPIBackend) {
+ if cfg == nil || backend == nil {
+ return
+ }
+ backend.config = cfg
+ options := objectValue(cfg.provider(orclient.Service)["options"])
+ if value, exists := options["timeout"]; exists {
+ if disabled, ok := value.(bool); ok && !disabled {
+ backend.totalTimeoutMS = -1
+ } else if number, ok := configNumber(value); ok {
+ if number == 0 {
+ backend.totalTimeoutMS = -1
+ } else if number > 0 {
+ backend.totalTimeoutMS = number
+ }
+ }
+ }
+ if value, exists := options["chunkTimeout"]; exists {
+ if disabled, ok := value.(bool); ok && !disabled {
+ backend.chunkTimeoutMS = -1
+ } else if number, ok := configNumber(value); ok {
+ if number == 0 {
+ backend.chunkTimeoutMS = -1
+ } else if number > 0 {
+ backend.chunkTimeoutMS = number
+ }
+ }
+ }
+}
+
+func objectValue(value any) map[string]any {
+ object, _ := value.(map[string]any)
+ return object
+}
+
+func configNumber(value any) (float64, bool) {
+ switch value := value.(type) {
+ case float64:
+ return value, true
+ case float32:
+ return float64(value), true
+ case int:
+ return float64(value), true
+ case int64:
+ return float64(value), true
+ case uint64:
+ return float64(value), true
+ case json.Number:
+ number, err := value.Float64()
+ return number, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func splitConfiguredModel(value string) (string, string) {
+ providerID, modelID, found := strings.Cut(value, "/")
+ if !found {
+ return orclient.Service, value
+ }
+ return providerID, modelID
+}
diff --git a/internal/seniordev/app/config_live_test.go b/internal/seniordev/app/config_live_test.go
new file mode 100644
index 0000000000..245a9a6a6b
--- /dev/null
+++ b/internal/seniordev/app/config_live_test.go
@@ -0,0 +1,262 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/permission"
+)
+
+func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) {
+ workspace := t.TempDir()
+ global := t.TempDir()
+ t.Setenv("SENIOR_DEV_CONFIG_DIR", global)
+ t.Setenv("SENIOR_DEV_CONFIG", "")
+ t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "")
+ t.Setenv("SENIOR_DEV_PERMISSION", "")
+ shell := filepath.Join(global, "configured-shell")
+ if err := os.WriteFile(shell, []byte("#!/bin/sh\nprintf 'custom-config-dir-shell\\n'\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ shellJSON, _ := json.Marshal(shell)
+ if err := os.WriteFile(filepath.Join(global, "senior-dev.json"), []byte(`{
+ "shell": `+string(shellJSON)+`,
+ "tools": {"read": false}
+}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(workspace, "EXTRA.md"), []byte("PROJECT CONFIG INSTRUCTION"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ configText := `{
+ "instructions": ["EXTRA.md"],
+ "permission": {"edit": "deny", "read": "allow"},
+ "agent": {
+ "coder": {
+ "prompt": "configured coder prompt",
+ "model": "openrouter/vendor/configured-model",
+ "temperature": 0.25,
+ "tools": {"bash": false},
+ "options": {"agent_option": true}
+ }
+ },
+ "provider": {
+ "openrouter": {
+ "options": {
+ "timeout": false,
+ "chunkTimeout": 45000,
+ "headers": {"X-Config": "provider", "X-Provider": "yes"}
+ },
+ "models": {
+ "vendor/configured-model": {
+ "limit": {"context": 64000, "output": 4096},
+ "headers": {"X-Config": "model"},
+ "options": {"model_option": "configured"}
+ }
+ }
+ }
+ }
+}`
+ if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(configText), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtime := newConfiguredRuntime(workspace, &capturingBackend{}, cfg)
+ defer runtime.Close()
+
+ // Project instructions reach the live system prompt service.
+ instructions := strings.Join(runtime.registry.SystemInstructions(context.Background()), "\n")
+ if !strings.Contains(instructions, "PROJECT CONFIG INSTRUCTION") {
+ t.Fatalf("configured instructions missing from live registry: %q", instructions)
+ }
+ // The registry consumes the exact loader configured by SENIOR_DEV_CONFIG_DIR,
+ // including shell/formatter settings.
+ bashInput, _ := json.Marshal(map[string]any{"command": "ignored"})
+ bashResult, err := runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ Name: "bash", Input: bashInput, SessionID: "ses_config",
+ })
+ if err != nil || !strings.Contains(bashResult.Output, "custom-config-dir-shell") {
+ t.Fatalf("custom config shell result = %#v, %v", bashResult, err)
+ }
+ // The tools block is normalized after config merging, so the explicit
+ // project permission remains authoritative.
+ if rule := permission.Evaluate("read", "anything", cfg.global); rule.Action != permission.ActionAllow {
+ t.Fatalf("merged tools/permission rule = %+v, want allow", rule)
+ }
+
+ // A project deny policy blocks the live tool executor with a permission.DeniedError.
+ input, _ := json.Marshal(map[string]any{
+ "filePath": filepath.Join(workspace, "blocked.txt"), "content": "blocked",
+ })
+ _, err = runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ Name: "write", Input: input, Agent: "coder",
+ })
+ var denied permission.DeniedError
+ if !errors.As(err, &denied) {
+ t.Fatalf("configured write error = %T %v", err, err)
+ }
+
+ configured, err := cfg.configureTurn(turn{
+ Agent: "coder", AgentMarkdown: "baked", ProviderID: "openrouter", ModelID: "old",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if configured.AgentMarkdown != "configured coder prompt" ||
+ configured.ProviderID != "openrouter" || configured.ModelID != "vendor/configured-model" {
+ t.Fatalf("configured turn = %+v", configured)
+ }
+ definitions := runtime.definitionsFor(configured.ProviderID, configured.ModelID, "coder", nil)
+ for _, definition := range definitions {
+ if definition.Provider.Name == "bash" || definition.Provider.Name == "apply_patch" ||
+ definition.Provider.Name == "edit" || definition.Provider.Name == "write" {
+ t.Fatalf("denied tool %q remained advertised", definition.Provider.Name)
+ }
+ }
+
+ backend := &modelAPIBackend{}
+ cfg.applyBackend(backend)
+ model, err := (seniorDevModels{
+ backend: backend, sessionID: "ses", agent: "coder",
+ }).GetModel(context.Background(), "openrouter", "vendor/configured-model")
+ if err != nil {
+ t.Fatal(err)
+ }
+ options, _ := model.Params.OpenRouterOptions.MarshalJSON()
+ if backend.totalTimeoutMS != -1 || backend.chunkTimeoutMS != 45000 ||
+ !strings.Contains(string(options), `"model_option":"configured"`) ||
+ !strings.Contains(string(options), `"agent_option":true`) ||
+ model.Params.MaxOutputTokens == nil || *model.Params.MaxOutputTokens != 4096 {
+ t.Fatalf("provider/model config not consumed: backend=%+v options=%s model=%+v", backend, options, model)
+ }
+ headers := seniorDevHeaders("ses", cfg.headers("openrouter", "vendor/configured-model"))
+ headerText, _ := json.Marshal(headers)
+ if !strings.Contains(string(headerText), `"name":"x-config","value":"model"`) ||
+ !strings.Contains(string(headerText), `"name":"x-provider","value":"yes"`) {
+ t.Fatalf("configured headers not consumed: %s", headerText)
+ }
+}
+
+// The three knobs that used to decide how senior-dev reached a model are
+// refused by name, never quietly ignored: a service's apiKey and baseURL,
+// which would be a second road to a model codeaf could not meter, and any
+// providerRouting block, which codeaf's model funnel now decides.
+func TestConfigRefusesTheRetiredModelKnobsByName(t *testing.T) {
+ for _, test := range []struct {
+ name, config, want string
+ }{
+ {"api key", `{"provider":{"openrouter":{"options":{"apiKey":"sk-anything"}}}}`, "options.apiKey is not read"},
+ {"base url", `{"provider":{"openrouter":{"options":{"baseURL":"https://elsewhere.example/v1"}}}}`, "options.baseURL is not read"},
+ {"provider routing", `{"provider":{"openrouter":{"providerRouting":{"sort":"price"}}}}`, "providerRouting is not read"},
+ {"model routing", `{"provider":{"openrouter":{"models":{"vendor/m":{"providerRouting":{"sort":"price"}}}}}}`, "providerRouting is not read"},
+ {"agent routing", `{"agent":{"coder":{"providerRouting":{"sort":"price"}}}}`, "providerRouting is not read"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ workspace := t.TempDir()
+ t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir())
+ t.Setenv("SENIOR_DEV_CONFIG", "")
+ t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "")
+ t.Setenv("SENIOR_DEV_PERMISSION", "")
+ if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(test.config), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := loadSeniorDevConfig(workspace)
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("load error = %v, want one saying %q", err, test.want)
+ }
+ })
+ }
+}
+
+// A routing preference spelled as a plain request option never leaves the
+// program either: the `provider` key is taken out of the options every call
+// carries, whoever put it there, and the rest of the options survive.
+func TestAnAdHocProviderOptionIsDroppedFromTheRequest(t *testing.T) {
+ cfg, err := newSeniorDevConfig(map[string]any{
+ "agent": map[string]any{"coder": map[string]any{"options": map[string]any{
+ "provider": map[string]any{"order": []any{"somewhere"}},
+ "agent_option": true,
+ }}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend := &modelAPIBackend{api: testModelAPI}
+ cfg.applyBackend(backend)
+ model, err := (seniorDevModels{backend: backend, sessionID: "ses", agent: "coder"}).
+ GetModel(context.Background(), "openrouter", "vendor/model")
+ if err != nil {
+ t.Fatal(err)
+ }
+ options, _ := model.Params.OpenRouterOptions.MarshalJSON()
+ if strings.Contains(string(options), `"provider"`) || !strings.Contains(string(options), `"agent_option":true`) {
+ t.Fatalf("options = %s, want the agent's option without any provider routing", options)
+ }
+}
+
+func TestSeniorDevPermissionEnvironmentPreservesLastMatchOrder(t *testing.T) {
+ // SENIOR_DEV_PERMISSION object order survives config loading because
+ // last-match-wins evaluation is observable behavior.
+ workspace := t.TempDir()
+ t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir())
+ t.Setenv("SENIOR_DEV_CONFIG", "")
+ t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "")
+ t.Setenv("SENIOR_DEV_PERMISSION", `{"read":"allow","*":"deny"}`)
+ cfg, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rule := permission.Evaluate("read", "README.md", cfg.global); rule.Action != permission.ActionDeny {
+ t.Fatalf("ordered env permission = %+v, want trailing wildcard deny", rule)
+ }
+}
+
+func TestSeniorDevConfigDirFeedsRegistryFormatterContract(t *testing.T) {
+ // Formatter lookup shares the pipeline's SENIOR_DEV_CONFIG_DIR-aware loader
+ // instead of constructing a default loader.
+ workspace := t.TempDir()
+ global := t.TempDir()
+ t.Setenv("SENIOR_DEV_CONFIG_DIR", global)
+ t.Setenv("SENIOR_DEV_CONFIG", "")
+ t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "")
+ t.Setenv("SENIOR_DEV_PERMISSION", "")
+ formatter := filepath.Join(global, "formatter")
+ if err := os.WriteFile(formatter, []byte("#!/bin/sh\nprintf 'formatted-by-custom-dir\\n' > \"$1\"\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ formatterJSON, _ := json.Marshal(formatter)
+ configText := `{"formatter":{"custom":{"extensions":[".fmtx"],"command":[` +
+ string(formatterJSON) + `,"$FILE"]}}}`
+ if err := os.WriteFile(filepath.Join(global, "senior-dev.json"), []byte(configText), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtime := newConfiguredRuntime(workspace, &capturingBackend{}, cfg)
+ defer runtime.Close()
+ target := filepath.Join(workspace, "sample.fmtx")
+ input, _ := json.Marshal(map[string]any{"filePath": target, "content": "unformatted\n"})
+ if _, err := runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ Name: "write", Input: input, SessionID: "ses_formatter",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(target)
+ if err != nil || string(body) != "formatted-by-custom-dir\n" {
+ t.Fatalf("custom-dir formatter output = %q, %v", body, err)
+ }
+}
diff --git a/internal/seniordev/app/crew_test.go b/internal/seniordev/app/crew_test.go
new file mode 100644
index 0000000000..b0100be85a
--- /dev/null
+++ b/internal/seniordev/app/crew_test.go
@@ -0,0 +1,62 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// A crew's models are pool entries under the model API's service, and one
+// already filed there is left as it is.
+func TestCrewModelFilesASeatUnderTheService(t *testing.T) {
+ for in, want := range map[string]string{
+ "z-ai/glm-5.3-flash": "openrouter/z-ai/glm-5.3-flash",
+ "openrouter/z-ai/glm-5.3-flash": "openrouter/z-ai/glm-5.3-flash",
+ " ": "",
+ } {
+ if got := CrewModel(in); got != want {
+ t.Errorf("CrewModel(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// Pools a crew filled keep only what the catalog can size, say what they
+// dropped, and route on senior-dev's own list when the working seat is left
+// with nothing.
+func TestCrewPoolsDropWhatTheCatalogLacksAndFallBackToTheOwnList(t *testing.T) {
+ known := func(ref string) bool { return !strings.Contains(ref, "unknown") }
+ var notes bytes.Buffer
+ args := crewPools(cliArgs{
+ High: "openrouter/vendor/hands",
+ Frontier: "openrouter/vendor/unknown-brain",
+ Low: "openrouter/vendor/light",
+ }, known, ¬es)
+ if args.High != "openrouter/vendor/hands" || args.Frontier != "" || args.Low != "openrouter/vendor/light" {
+ t.Fatalf("pools = %+v, want the unknown frontier dropped and the rest kept", args)
+ }
+ if !strings.Contains(notes.String(), "openrouter/vendor/unknown-brain is not in the model catalog") {
+ t.Fatalf("notes = %q, want the dropped model named", notes.String())
+ }
+ notes.Reset()
+ if args := crewPools(cliArgs{High: "openrouter/vendor/unknown-hands"}, known, ¬es); args.High != DefaultHighModels {
+ t.Fatalf("an unusable working seat left --high = %q, want senior-dev's own list", args.High)
+ }
+ if !strings.Contains(notes.String(), "routing on senior-dev's own list") {
+ t.Fatalf("notes = %q, want the fallback said", notes.String())
+ }
+}
+
+// A model the person asked for that the catalog cannot size is a refusal that
+// names it, before any call; one it can size is no refusal.
+func TestAnAskedModelTheCatalogCannotSizeIsRefusedByName(t *testing.T) {
+ known := func(ref string) bool { return ref == "openrouter/vendor/known" }
+ if got := askedRefusal("openrouter/vendor/known", known); got != "" {
+ t.Fatalf("a known model was refused: %q", got)
+ }
+ got := askedRefusal("openrouter/vendor/known,openrouter/proxy/mystery", known)
+ if !strings.HasPrefix(got, "senior-dev cannot work with proxy/mystery: ") || !strings.Contains(got, "nothing was started") {
+ t.Fatalf("refusal = %q", got)
+ }
+}
diff --git a/internal/seniordev/app/durable_sessions.go b/internal/seniordev/app/durable_sessions.go
new file mode 100644
index 0000000000..cc366d9540
--- /dev/null
+++ b/internal/seniordev/app/durable_sessions.go
@@ -0,0 +1,1012 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/project"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/projectors"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore"
+ "github.com/Agent-Field/codeaf/internal/seniordev/storage"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ seniorDevDataDirectory = ".senior-dev"
+ seniorDevDatabaseFile = "senior-dev.db"
+
+ projectionReconcileVersion = 1
+)
+
+type projectionSource interface {
+ List(prefix []string) ([][]string, error)
+ ReadInto(key []string, dst any) error
+}
+
+type projectionRecordMark struct {
+ Key string `json:"key"`
+ Size int64 `json:"size"`
+ Modified int64 `json:"modified"`
+ Changed int64 `json:"changed"`
+ Device uint64 `json:"device"`
+ Inode uint64 `json:"inode"`
+}
+
+type projectionManifest struct {
+ Version int `json:"version"`
+ ProjectID string `json:"projectID"`
+ Records []projectionRecordMark `json:"records"`
+}
+
+type durableSessions struct {
+ store *storage.Store
+ projectionSource projectionSource
+ sessions *sessioncore.Service
+ bus *bus.Bus
+ db *sql.DB
+ projectID string
+ workspace string
+ unsubscribe func()
+ projector *projectors.Store
+ lockPath string
+
+ operationMu sync.Mutex
+ projectionMu sync.Mutex
+ projectionError []error
+}
+
+func openDurableSessions(ctx context.Context, workspace string) (*durableSessions, error) {
+ // Flat session storage and senior-dev.db both live under the workspace's
+ // .senior-dev directory.
+ dataDir := filepath.Join(workspace, seniorDevDataDirectory)
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ return nil, fmt.Errorf("senior-dev sessions: create data directory: %w", err)
+ }
+
+ projectInfo, _, err := project.Discover(ctx, workspace)
+ if err != nil {
+ return nil, fmt.Errorf("senior-dev sessions: discover project: %w", err)
+ }
+ projectID := string(projectInfo.ID)
+ dbPath := filepath.Join(dataDir, seniorDevDatabaseFile)
+ db, err := projectors.Open(ctx, dbPath, projectors.BusyRetryOptions{Log: io.Discard})
+ if err != nil {
+ return nil, err
+ }
+ closeOnError := func(err error) (*durableSessions, error) {
+ _ = db.Close()
+ return nil, err
+ }
+ if err := applyProjectSchema(ctx, db); err != nil {
+ return closeOnError(err)
+ }
+ if err := upsertProject(ctx, db, projectInfo); err != nil {
+ return closeOnError(err)
+ }
+ if err := projectors.ApplySchema(ctx, db); err != nil {
+ return closeOnError(err)
+ }
+
+ instanceBus := bus.New(bus.Context{
+ Directory: workspace, Project: projectID, Workspace: workspace,
+ })
+ durable := &durableSessions{
+ store: storage.NewFromDataDir(dataDir), bus: instanceBus, db: db,
+ projectID: projectID, workspace: workspace,
+ projector: projectors.NewStore(db, projectors.StoreOptions{}),
+ lockPath: filepath.Join(dataDir, "projection.lock"),
+ }
+ durable.projectionSource = durable.store
+ sessions, err := sessioncore.New(sessioncore.Options{
+ Store: durable.store, Bus: instanceBus, ProjectID: projectID,
+ Worktree: string(projectInfo.Worktree), Directory: workspace,
+ Version: version,
+ })
+ if err != nil {
+ durable.Close()
+ return nil, err
+ }
+ durable.sessions = sessions
+ if err := durable.reconcileProjection(ctx); err != nil {
+ durable.Close()
+ return nil, err
+ }
+ durable.unsubscribe = instanceBus.SubscribeAllCallback(durable.projectEvent)
+ return durable, nil
+}
+
+func applyProjectSchema(ctx context.Context, db *sql.DB) error {
+ _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS project (
+ id text PRIMARY KEY,
+ worktree text NOT NULL,
+ vcs text,
+ name text,
+ icon_url text,
+ icon_url_override text,
+ icon_color text,
+ time_created integer NOT NULL,
+ time_updated integer NOT NULL,
+ time_initialized integer,
+ sandboxes text NOT NULL,
+ commands text
+ )`)
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: apply project schema: %w", err)
+ }
+ return nil
+}
+
+func upsertProject(ctx context.Context, db *sql.DB, info project.Info) error {
+ now := time.Now().UnixMilli()
+ created := info.Time.Created
+ if created == 0 {
+ created = now
+ }
+ updated := info.Time.Updated
+ if updated == 0 {
+ updated = now
+ }
+ sandboxes := info.Sandboxes
+ if len(sandboxes) == 0 {
+ sandboxes = []string{info.Worktree}
+ }
+ sandboxesJSON, err := json.Marshal(sandboxes)
+ if err != nil {
+ return err
+ }
+ var vcs any
+ if info.VCS != nil {
+ vcs = *info.VCS
+ }
+ _, err = db.ExecContext(ctx, `INSERT INTO project
+ (id, worktree, vcs, time_created, time_updated, sandboxes)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ worktree = excluded.worktree,
+ vcs = excluded.vcs,
+ time_updated = excluded.time_updated,
+ sandboxes = excluded.sandboxes`,
+ string(info.ID), info.Worktree, vcs, created, updated, string(sandboxesJSON),
+ )
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: upsert project: %w", err)
+ }
+ return nil
+}
+
+func projectedSessionEvent(eventType string) bool {
+ switch eventType {
+ case projectors.EventSessionCreated,
+ projectors.EventSessionUpdated,
+ projectors.EventSessionDeleted,
+ projectors.EventMessageUpdated,
+ projectors.EventMessageRemoved,
+ projectors.EventMessagePartUpdated,
+ projectors.EventMessagePartRemoved:
+ return true
+ default:
+ return false
+ }
+}
+
+func (durable *durableSessions) recordProjectionError(err error) {
+ if err == nil {
+ return
+ }
+ durable.projectionMu.Lock()
+ durable.projectionError = append(durable.projectionError, err)
+ durable.projectionMu.Unlock()
+}
+
+func (durable *durableSessions) projectEvent(payload bus.Payload) {
+ if !projectedSessionEvent(payload.Type) {
+ return
+ }
+ data, err := json.Marshal(payload.Properties)
+ if err == nil {
+ err = durable.projector.Apply(context.Background(), projectors.Event{
+ ID: payload.ID, Type: payload.Type, Data: data,
+ })
+ }
+ durable.recordProjectionError(err)
+}
+
+func (durable *durableSessions) withProjection(
+ operation string, mutate func() error,
+) error {
+ durable.operationMu.Lock()
+ defer durable.operationMu.Unlock()
+ return withAdvisoryFileLock(durable.lockPath, func() error {
+ durable.projectionMu.Lock()
+ durable.projectionError = nil
+ durable.projectionMu.Unlock()
+ mutationErr := mutate()
+ durable.projectionMu.Lock()
+ projectionErrs := durable.projectionError
+ durable.projectionError = nil
+ durable.projectionMu.Unlock()
+ if mutationErr != nil {
+ return mutationErr
+ }
+ if len(projectionErrs) != 0 {
+ return fmt.Errorf("senior-dev sessions: project %s: %w", operation, errors.Join(projectionErrs...))
+ }
+ // Live projectors have already committed. Move only the O(1) database
+ // generation here; the filesystem manifest deliberately remains at the
+ // last startup so that the next startup discovers the new log records.
+ if err := durable.advanceProjectionGeneration(context.Background()); err != nil {
+ log.Printf("senior-dev sessions: leave projection generation stale after %s: %v", operation, err)
+ }
+ return nil
+ })
+}
+
+func (durable *durableSessions) Messages(
+ ctx context.Context, sessionID string,
+) ([]msgmodel.WithParts, error) {
+ return durable.sessions.Messages(ctx, sessionID)
+}
+
+func (durable *durableSessions) UpdateMessage(ctx context.Context, info msgmodel.Info) error {
+ return durable.withProjection("message "+info.MessageID(), func() error {
+ return durable.sessions.UpdateMessage(ctx, info)
+ })
+}
+
+func (durable *durableSessions) UpdatePart(ctx context.Context, part msgmodel.Part) error {
+ return durable.withProjection("part "+part.Base().ID, func() error {
+ return durable.sessions.UpdatePart(ctx, part)
+ })
+}
+
+func (durable *durableSessions) UpdatePartDelta(ctx context.Context, input msgmodel.PartDeltaEvent) {
+ durable.sessions.UpdatePartDelta(ctx, input)
+}
+
+func (durable *durableSessions) UpdateMessageWithParts(
+ ctx context.Context, info msgmodel.Info, parts ...msgmodel.Part,
+) error {
+ return durable.withProjection("message turn "+info.MessageID(), func() error {
+ return durable.sessions.UpdateMessageWithParts(ctx, info, parts...)
+ })
+}
+
+func (durable *durableSessions) CreateSession(
+ ctx context.Context, input sessioncore.CreateInput,
+) (sessioncore.Info, error) {
+ var info sessioncore.Info
+ err := durable.withProjection("session create", func() error {
+ var err error
+ info, err = durable.sessions.Create(ctx, input)
+ return err
+ })
+ return info, err
+}
+
+func (durable *durableSessions) TouchSession(ctx context.Context, sessionID string) error {
+ return durable.withProjection("session touch "+sessionID, func() error {
+ return durable.sessions.Touch(ctx, sessionID)
+ })
+}
+
+func (durable *durableSessions) RemoveSession(ctx context.Context, sessionID string) error {
+ return durable.withProjection("session remove "+sessionID, func() error {
+ return durable.sessions.Remove(ctx, sessionID)
+ })
+}
+
+type replayProjectionEvent struct {
+ event projectors.Event
+ time int64
+}
+
+func (durable *durableSessions) reconcileProjection(ctx context.Context) error {
+ return withAdvisoryFileLock(durable.lockPath, func() error {
+ if err := durable.ensureProjectionReconcileSchema(ctx); err != nil {
+ return err
+ }
+ current, err := durable.projectionManifest()
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: inspect flat projection source: %w", err)
+ }
+ previous, markedGeneration, valid, err := durable.loadProjectionMark(ctx)
+ if err != nil {
+ return err
+ }
+ generation, err := durable.projectionGeneration(ctx, durable.db)
+ if err != nil {
+ return err
+ }
+ if valid && generation == markedGeneration {
+ changed, removed := changedProjectionRecords(previous, current)
+ if len(changed) == 0 && !removed {
+ return nil
+ }
+ if !removed {
+ events, quarantined, eventErr := durable.incrementalProjectionEvents(changed, current)
+ if eventErr != nil {
+ return fmt.Errorf("senior-dev sessions: read incremental projection source: %w", eventErr)
+ }
+ if !quarantined {
+ return durable.commitProjectionReplay(ctx, events, current, false)
+ }
+ }
+ }
+ return durable.fullProjectionReconcile(ctx)
+ })
+}
+
+const projectionReconcileSchema = `
+CREATE TABLE IF NOT EXISTS senior_dev_projection_generation (
+ id integer PRIMARY KEY CHECK (id = 1),
+ generation integer NOT NULL
+);
+INSERT OR IGNORE INTO senior_dev_projection_generation (id, generation) VALUES (1, 0);
+CREATE TABLE IF NOT EXISTS senior_dev_projection_reconcile (
+ id integer PRIMARY KEY CHECK (id = 1),
+ format_version integer NOT NULL,
+ project_id text NOT NULL,
+ generation integer NOT NULL,
+ manifest text NOT NULL
+);
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_insert
+AFTER INSERT ON session BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_update
+AFTER UPDATE ON session BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_delete
+AFTER DELETE ON session BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_insert
+AFTER INSERT ON message BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_update
+AFTER UPDATE ON message BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_delete
+AFTER DELETE ON message BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_insert
+AFTER INSERT ON part BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_update
+AFTER UPDATE ON part BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;
+CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_delete
+AFTER DELETE ON part BEGIN
+ UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1;
+END;`
+
+func (durable *durableSessions) ensureProjectionReconcileSchema(ctx context.Context) error {
+ if _, err := durable.db.ExecContext(ctx, projectionReconcileSchema); err != nil {
+ return fmt.Errorf("senior-dev sessions: apply projection reconciliation schema: %w", err)
+ }
+ return nil
+}
+
+type projectionGenerationReader interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
+
+func (durable *durableSessions) projectionGeneration(
+ ctx context.Context, reader projectionGenerationReader,
+) (int64, error) {
+ var generation int64
+ if err := reader.QueryRowContext(ctx,
+ "SELECT generation FROM senior_dev_projection_generation WHERE id = 1",
+ ).Scan(&generation); err != nil {
+ return 0, fmt.Errorf("senior-dev sessions: read projection generation: %w", err)
+ }
+ return generation, nil
+}
+
+func (durable *durableSessions) loadProjectionMark(
+ ctx context.Context,
+) (projectionManifest, int64, bool, error) {
+ var version int
+ var projectID, raw string
+ var generation int64
+ err := durable.db.QueryRowContext(ctx, `SELECT format_version, project_id, generation, manifest
+ FROM senior_dev_projection_reconcile WHERE id = 1`).Scan(
+ &version, &projectID, &generation, &raw,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return projectionManifest{}, 0, false, nil
+ }
+ if err != nil {
+ return projectionManifest{}, 0, false,
+ fmt.Errorf("senior-dev sessions: read projection reconciliation mark: %w", err)
+ }
+ var manifest projectionManifest
+ if version != projectionReconcileVersion || projectID != durable.projectID ||
+ json.Unmarshal([]byte(raw), &manifest) != nil || !validProjectionManifest(manifest, durable.projectID) {
+ return projectionManifest{}, 0, false, nil
+ }
+ return manifest, generation, true, nil
+}
+
+func validProjectionManifest(manifest projectionManifest, projectID string) bool {
+ if manifest.Version != projectionReconcileVersion || manifest.ProjectID != projectID {
+ return false
+ }
+ previous := ""
+ for _, record := range manifest.Records {
+ if record.Key == "" || record.Key <= previous {
+ return false
+ }
+ parts := strings.Split(record.Key, "/")
+ if len(parts) < 2 || (parts[0] != "session" && parts[0] != "message" && parts[0] != "part") {
+ return false
+ }
+ previous = record.Key
+ }
+ return true
+}
+
+func (durable *durableSessions) projectionManifest() (projectionManifest, error) {
+ records := []projectionRecordMark{}
+ for _, prefix := range []string{"session", "message", "part"} {
+ keys, err := durable.projectionSource.List([]string{prefix})
+ if err != nil {
+ return projectionManifest{}, err
+ }
+ for _, key := range keys {
+ if !validProjectionKey(key) {
+ continue
+ }
+ pathParts := append([]string{durable.store.Dir}, key...)
+ path := filepath.Join(pathParts...) + ".json"
+ info, err := os.Stat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return projectionManifest{}, err
+ }
+ record := projectionRecordMark{
+ Key: strings.Join(key, "/"), Size: info.Size(), Modified: info.ModTime().UnixNano(),
+ }
+ if stat, ok := info.Sys().(*syscall.Stat_t); ok {
+ record.Device = uint64(stat.Dev)
+ record.Inode = stat.Ino
+ record.Changed = statChangedNanos(stat)
+ }
+ records = append(records, record)
+ }
+ }
+ sort.Slice(records, func(i, j int) bool { return records[i].Key < records[j].Key })
+ return projectionManifest{
+ Version: projectionReconcileVersion, ProjectID: durable.projectID, Records: records,
+ }, nil
+}
+
+func validProjectionKey(key []string) bool {
+ if len(key) == 2 && key[0] == "session" {
+ return true
+ }
+ if len(key) == 3 && key[0] == "message" {
+ return true
+ }
+ return len(key) == 4 && key[0] == "part"
+}
+
+func changedProjectionRecords(previous, current projectionManifest) ([]projectionRecordMark, bool) {
+ old := make(map[string]projectionRecordMark, len(previous.Records))
+ for _, record := range previous.Records {
+ old[record.Key] = record
+ }
+ changed := make([]projectionRecordMark, 0)
+ for _, record := range current.Records {
+ if prior, exists := old[record.Key]; !exists || prior != record {
+ changed = append(changed, record)
+ }
+ delete(old, record.Key)
+ }
+ return changed, len(old) != 0
+}
+
+func (durable *durableSessions) writeProjectionMarkTx(
+ ctx context.Context, tx *sql.Tx, manifest projectionManifest,
+) error {
+ raw, err := json.Marshal(manifest)
+ if err != nil {
+ return err
+ }
+ generation, err := durable.projectionGeneration(ctx, tx)
+ if err != nil {
+ return err
+ }
+ _, err = tx.ExecContext(ctx, `INSERT INTO senior_dev_projection_reconcile
+ (id, format_version, project_id, generation, manifest) VALUES (1, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET format_version = excluded.format_version,
+ project_id = excluded.project_id, generation = excluded.generation, manifest = excluded.manifest`,
+ projectionReconcileVersion, durable.projectID, generation, string(raw),
+ )
+ return err
+}
+
+func (durable *durableSessions) advanceProjectionGeneration(ctx context.Context) error {
+ _, err := durable.db.ExecContext(ctx, `UPDATE senior_dev_projection_reconcile
+ SET generation = (SELECT generation FROM senior_dev_projection_generation WHERE id = 1)
+ WHERE id = 1`)
+ return err
+}
+
+func (durable *durableSessions) commitProjectionReplay(
+ ctx context.Context, events []replayProjectionEvent, manifest projectionManifest, prune bool,
+) error {
+ tx, err := durable.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: begin projection replay: %w", err)
+ }
+ var replayTime int64
+ projector := projectors.NewStore(durable.db, projectors.StoreOptions{
+ Now: func() int64 { return replayTime },
+ })
+ if prune {
+ if err := durable.pruneProjectionTx(ctx, tx, events); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("senior-dev sessions: prune projection replay: %w", err)
+ }
+ }
+ for _, item := range events {
+ replayTime = item.time
+ if err := projector.ApplyReconcileTx(ctx, tx, item.event); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("senior-dev sessions: replay %s: %w", item.event.Type, err)
+ }
+ }
+ if err := durable.writeProjectionMarkTx(ctx, tx, manifest); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("senior-dev sessions: write projection reconciliation mark: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("senior-dev sessions: commit projection replay: %w", err)
+ }
+ return nil
+}
+
+func (durable *durableSessions) pruneProjectionTx(
+ ctx context.Context, tx *sql.Tx, events []replayProjectionEvent,
+) error {
+ ids := map[string][]string{"session": {}, "message": {}, "part": {}}
+ for _, item := range events {
+ var data struct {
+ SessionID string `json:"sessionID"`
+ Info struct {
+ ID string `json:"id"`
+ } `json:"info"`
+ Part struct {
+ ID string `json:"id"`
+ } `json:"part"`
+ }
+ if err := json.Unmarshal(item.event.Data, &data); err != nil {
+ return err
+ }
+ switch item.event.Type {
+ case projectors.EventSessionCreated:
+ ids["session"] = append(ids["session"], data.SessionID)
+ case projectors.EventMessageUpdated:
+ ids["message"] = append(ids["message"], data.Info.ID)
+ case projectors.EventMessagePartUpdated:
+ ids["part"] = append(ids["part"], data.Part.ID)
+ }
+ }
+ encoded := map[string]string{}
+ for kind, values := range ids {
+ raw, err := json.Marshal(values)
+ if err != nil {
+ return err
+ }
+ encoded[kind] = string(raw)
+ }
+ for _, statement := range []struct {
+ query string
+ args []any
+ }{
+ {
+ query: `DELETE FROM part WHERE session_id IN
+ (SELECT id FROM session WHERE project_id = ?)
+ AND id NOT IN (SELECT value FROM json_each(?))`,
+ args: []any{durable.projectID, encoded["part"]},
+ },
+ {
+ query: `DELETE FROM message WHERE session_id IN
+ (SELECT id FROM session WHERE project_id = ?)
+ AND id NOT IN (SELECT value FROM json_each(?))`,
+ args: []any{durable.projectID, encoded["message"]},
+ },
+ {
+ query: `DELETE FROM session WHERE project_id = ?
+ AND id NOT IN (SELECT value FROM json_each(?))`,
+ args: []any{durable.projectID, encoded["session"]},
+ },
+ } {
+ if _, err := tx.ExecContext(ctx, statement.query, statement.args...); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (durable *durableSessions) fullProjectionReconcile(ctx context.Context) error {
+ events, _, err := durable.flatProjectionEvents()
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: read flat projection source: %w", err)
+ }
+ manifest, err := durable.projectionManifest()
+ if err != nil {
+ return fmt.Errorf("senior-dev sessions: inspect reconciled projection source: %w", err)
+ }
+ return durable.commitProjectionReplay(ctx, events, manifest, true)
+}
+
+func (durable *durableSessions) incrementalProjectionEvents(
+ changed []projectionRecordMark, current projectionManifest,
+) ([]replayProjectionEvent, bool, error) {
+ changedByKind := map[string][]projectionRecordMark{}
+ currentKeys := make(map[string]bool, len(current.Records))
+ for _, record := range current.Records {
+ currentKeys[record.Key] = true
+ }
+ for _, record := range changed {
+ parts := strings.Split(record.Key, "/")
+ changedByKind[parts[0]] = append(changedByKind[parts[0]], record)
+ }
+
+ type sessionState struct {
+ checked bool
+ own bool
+ info sessioncore.Info
+ }
+ sessions := map[string]sessionState{}
+ quarantined := false
+ requiresFull := false
+ var sessionReadErr error
+ readSession := func(sessionID string) (sessioncore.Info, bool) {
+ if state := sessions[sessionID]; state.checked {
+ return state.info, state.own
+ }
+ key := []string{"session", sessionID}
+ var info sessioncore.Info
+ if err := durable.projectionSource.ReadInto(key, &info); err != nil {
+ durable.quarantineProjectionRecord(key, err)
+ quarantined = true
+ sessions[sessionID] = sessionState{checked: true}
+ return sessioncore.Info{}, false
+ }
+ if info.ID != sessionID {
+ requiresFull = true
+ }
+ state := sessionState{checked: true, own: info.ProjectID == durable.projectID, info: info}
+ if !state.own {
+ var projected bool
+ err := durable.db.QueryRow(
+ "SELECT EXISTS(SELECT 1 FROM session WHERE id = ? AND project_id = ?)",
+ sessionID, durable.projectID,
+ ).Scan(&projected)
+ if sessionReadErr == nil {
+ sessionReadErr = err
+ }
+ if projected {
+ requiresFull = true
+ }
+ }
+ sessions[sessionID] = state
+ return info, state.own
+ }
+
+ events := make([]replayProjectionEvent, 0, len(changed))
+ for _, record := range changedByKind["session"] {
+ key := strings.Split(record.Key, "/")
+ info, own := readSession(key[1])
+ if !own {
+ continue
+ }
+ event, err := projectionEvent(projectors.EventSessionCreated, map[string]any{
+ "sessionID": info.ID, "info": info,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(info.Time.Updated)})
+ }
+ for _, record := range changedByKind["message"] {
+ key := strings.Split(record.Key, "/")
+ info, own := readSession(key[1])
+ if !own {
+ continue
+ }
+ var raw json.RawMessage
+ if err := durable.projectionSource.ReadInto(key, &raw); err != nil {
+ durable.quarantineProjectionRecord(key, err)
+ quarantined = true
+ continue
+ }
+ message, err := msgmodel.UnmarshalInfo(raw)
+ if err != nil {
+ durable.quarantineProjectionRecord(key, err)
+ quarantined = true
+ continue
+ }
+ if message.MessageID() != key[2] || projectionMessageSessionID(message) != key[1] {
+ requiresFull = true
+ }
+ created := messageCreatedMS(message)
+ event, err := projectionEvent(projectors.EventMessageUpdated, msgmodel.UpdatedEvent{
+ SessionID: info.ID, Info: message,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(created)})
+ }
+ for _, record := range changedByKind["part"] {
+ key := strings.Split(record.Key, "/")
+ info, own := readSession(key[1])
+ if !own || !currentKeys[strings.Join([]string{"message", key[1], key[2]}, "/")] {
+ continue
+ }
+ var raw json.RawMessage
+ if err := durable.projectionSource.ReadInto(key, &raw); err != nil {
+ durable.quarantineProjectionRecord(key, err)
+ quarantined = true
+ continue
+ }
+ part, err := msgmodel.UnmarshalPart(raw)
+ if err != nil {
+ durable.quarantineProjectionRecord(key, err)
+ quarantined = true
+ continue
+ }
+ base := part.Base()
+ if base.ID != key[3] || base.SessionID != key[1] || base.MessageID != key[2] {
+ requiresFull = true
+ }
+ created := uint64(0)
+ var messageRaw json.RawMessage
+ messageKey := []string{"message", key[1], key[2]}
+ if err := durable.projectionSource.ReadInto(messageKey, &messageRaw); err == nil {
+ if message, parseErr := msgmodel.UnmarshalInfo(messageRaw); parseErr == nil {
+ created = messageCreatedMS(message)
+ }
+ }
+ stamp := latestJSONTimestamp(part, created)
+ event, err := projectionEvent(projectors.EventMessagePartUpdated, msgmodel.PartUpdatedEvent{
+ SessionID: info.ID, Part: part, Time: stamp,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(stamp)})
+ }
+ if sessionReadErr != nil {
+ return nil, false, sessionReadErr
+ }
+ return events, quarantined || requiresFull, nil
+}
+
+func (durable *durableSessions) flatProjectionEvents() ([]replayProjectionEvent, bool, error) {
+ sessionKeys, err := durable.projectionSource.List([]string{"session"})
+ if err != nil {
+ return nil, false, err
+ }
+ events := make([]replayProjectionEvent, 0, len(sessionKeys))
+ quarantined := false
+ for _, sessionKey := range sessionKeys {
+ var info sessioncore.Info
+ if err := durable.projectionSource.ReadInto(sessionKey, &info); err != nil {
+ durable.quarantineProjectionRecord(sessionKey, err)
+ quarantined = true
+ continue
+ }
+ if info.ProjectID != durable.projectID {
+ continue
+ }
+ event, err := projectionEvent(projectors.EventSessionCreated, map[string]any{
+ "sessionID": info.ID, "info": info,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(info.Time.Updated)})
+
+ messageKeys, err := durable.projectionSource.List([]string{"message", info.ID})
+ if err != nil {
+ return nil, quarantined, err
+ }
+ for _, messageKey := range messageKeys {
+ var raw json.RawMessage
+ if err := durable.projectionSource.ReadInto(messageKey, &raw); err != nil {
+ durable.quarantineProjectionRecord(messageKey, err)
+ quarantined = true
+ continue
+ }
+ message, err := msgmodel.UnmarshalInfo(raw)
+ if err != nil {
+ durable.quarantineProjectionRecord(messageKey, err)
+ quarantined = true
+ continue
+ }
+ created := messageCreatedMS(message)
+ event, err := projectionEvent(projectors.EventMessageUpdated, msgmodel.UpdatedEvent{
+ SessionID: info.ID, Info: message,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(created)})
+
+ partKeys, err := durable.projectionSource.List([]string{"part", info.ID, message.MessageID()})
+ if err != nil {
+ return nil, quarantined, err
+ }
+ for _, partKey := range partKeys {
+ var partRaw json.RawMessage
+ if err := durable.projectionSource.ReadInto(partKey, &partRaw); err != nil {
+ durable.quarantineProjectionRecord(partKey, err)
+ quarantined = true
+ continue
+ }
+ part, err := msgmodel.UnmarshalPart(partRaw)
+ if err != nil {
+ durable.quarantineProjectionRecord(partKey, err)
+ quarantined = true
+ continue
+ }
+ stamp := latestJSONTimestamp(part, created)
+ event, err := projectionEvent(projectors.EventMessagePartUpdated, msgmodel.PartUpdatedEvent{
+ SessionID: info.ID, Part: part, Time: stamp,
+ })
+ if err != nil {
+ return nil, quarantined, err
+ }
+ events = append(events, replayProjectionEvent{event: event, time: int64(stamp)})
+ }
+ }
+ }
+ return events, quarantined, nil
+}
+
+func (durable *durableSessions) quarantineProjectionRecord(key []string, cause error) {
+ parts := append([]string{durable.store.Dir}, key...)
+ source := filepath.Join(parts...) + ".json"
+ relative, err := filepath.Rel(durable.store.Dir, source)
+ if err != nil {
+ relative = filepath.Base(source)
+ }
+ destination := filepath.Join(
+ durable.store.Dir,
+ "quarantine",
+ relative+".corrupt-"+strconv.FormatInt(time.Now().UnixNano(), 10),
+ )
+ moveErr := os.MkdirAll(filepath.Dir(destination), 0o755)
+ if moveErr == nil {
+ moveErr = os.Rename(source, destination)
+ }
+ if moveErr != nil {
+ log.Printf("senior-dev sessions: ignored unreadable projection source %s: %v (quarantine failed: %v)", source, cause, moveErr)
+ return
+ }
+ log.Printf("senior-dev sessions: quarantined unreadable projection source %s as %s: %v", source, destination, cause)
+}
+
+func projectionEvent(eventType string, properties any) (projectors.Event, error) {
+ data, err := json.Marshal(properties)
+ if err != nil {
+ return projectors.Event{}, err
+ }
+ return projectors.Event{Type: eventType, Data: data}, nil
+}
+
+func messageCreatedMS(info msgmodel.Info) uint64 {
+ switch message := info.(type) {
+ case msgmodel.User:
+ return message.Time.Created
+ case msgmodel.Assistant:
+ return message.Time.Created
+ default:
+ return 0
+ }
+}
+
+func projectionMessageSessionID(info msgmodel.Info) string {
+ switch message := info.(type) {
+ case msgmodel.User:
+ return message.SessionID
+ case msgmodel.Assistant:
+ return message.SessionID
+ default:
+ return ""
+ }
+}
+
+func latestJSONTimestamp(value any, fallback uint64) uint64 {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return fallback
+ }
+ var decoded any
+ decoder := json.NewDecoder(strings.NewReader(string(data)))
+ decoder.UseNumber()
+ if decoder.Decode(&decoded) != nil {
+ return fallback
+ }
+ latest := fallback
+ var visit func(any)
+ visit = func(value any) {
+ switch value := value.(type) {
+ case map[string]any:
+ for key, nested := range value {
+ if key == "start" || key == "end" || key == "created" || key == "updated" {
+ if number, ok := nested.(json.Number); ok {
+ if stamp, err := number.Int64(); err == nil && stamp >= 0 && uint64(stamp) > latest {
+ latest = uint64(stamp)
+ }
+ }
+ }
+ visit(nested)
+ }
+ case []any:
+ for _, nested := range value {
+ visit(nested)
+ }
+ }
+ }
+ visit(decoded)
+ return latest
+}
+
+func withAdvisoryFileLock(path string, fn func() error) error {
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
+ return err
+ }
+ defer unix.Flock(int(file.Fd()), unix.LOCK_UN)
+ return fn()
+}
+
+func (durable *durableSessions) Close() {
+ if durable == nil {
+ return
+ }
+ if durable.unsubscribe != nil {
+ durable.unsubscribe()
+ }
+ if durable.bus != nil {
+ durable.bus.Dispose()
+ }
+ if durable.db != nil {
+ _ = durable.db.Close()
+ }
+}
diff --git a/internal/seniordev/app/durable_sessions_test.go b/internal/seniordev/app/durable_sessions_test.go
new file mode 100644
index 0000000000..a2e0924d4e
--- /dev/null
+++ b/internal/seniordev/app/durable_sessions_test.go
@@ -0,0 +1,808 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore"
+ "github.com/Agent-Field/codeaf/internal/seniordev/storage"
+)
+
+func TestDurablePromptPersistsAndProjectsBeforeFirstModelCall(t *testing.T) {
+ // Session, message, and part records plus their projected views exist
+ // before the first provider call, and child-session lineage is durable.
+ workspace := t.TempDir()
+ type observation struct {
+ sessions int
+ messages int
+ parts int
+ dbRows [3]int
+ err error
+ }
+ seen := observation{}
+ var runtime *runtimeAdapter
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ seen.sessions, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "session"))
+ if seen.err != nil {
+ return nil, seen.err
+ }
+ seen.messages, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "message"))
+ if seen.err != nil {
+ return nil, seen.err
+ }
+ seen.parts, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "part"))
+ if seen.err != nil {
+ return nil, seen.err
+ }
+ for index, table := range []string{"session", "message", "part"} {
+ seen.err = runtime.durable.db.QueryRowContext(
+ request.Context(), "SELECT COUNT(*) FROM "+table,
+ ).Scan(&seen.dbRows[index])
+ if seen.err != nil {
+ return nil, seen.err
+ }
+ }
+ return recordedResponse(
+ request, http.StatusOK, "text/event-stream", chatReply("finished", 10),
+ ), nil
+ })}
+ runtime = newRuntime(workspace, &modelAPIBackend{api: testModelAPI, client: client})
+ defer runtime.Close()
+ rootID, err := runtime.Create(context.Background(), "", "coder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := runTestTurn(t, runtime, testTurn{
+ ParentSessionID: rootID, SessionTitle: "durable child",
+ Agent: "coder", ProviderID: "openrouter",
+ ModelID: "vendor/model", Workspace: workspace, Prompt: "persist me first",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.err != nil {
+ t.Fatal(seen.err)
+ }
+ if seen.sessions != 2 || seen.messages < 2 || seen.parts < 1 ||
+ seen.dbRows[0] != 2 || seen.dbRows[1] < 2 || seen.dbRows[2] < 1 {
+ t.Fatalf("provider-start persistence = files(%d,%d,%d) db%v",
+ seen.sessions, seen.messages, seen.parts, seen.dbRows)
+ }
+ child, err := runtime.durable.sessions.Get(context.Background(), result.SessionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if child.ParentID == nil || *child.ParentID != rootID {
+ t.Fatalf("child lineage = %#v, want parent %s", child.ParentID, rootID)
+ }
+ var projectedParent string
+ if err := runtime.durable.db.QueryRow(
+ "SELECT parent_id FROM session WHERE id = ?", result.SessionID,
+ ).Scan(&projectedParent); err != nil {
+ t.Fatal(err)
+ }
+ if projectedParent != rootID {
+ t.Fatalf("projected parent = %q, want %q", projectedParent, rootID)
+ }
+}
+
+func TestDurableStartupReconcilesFlatStorageContract(t *testing.T) {
+ // Flat JSON is authoritative and reconstructs a missing database
+ // projection, including a complete user turn.
+ workspace := t.TempDir()
+ runtime := newRuntime(workspace, &capturingBackend{})
+ t.Cleanup(runtime.Close)
+ rootID, err := runtime.Create(context.Background(), "", "coder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_replay", turn{
+ Agent: "coder", ProviderID: "p", ModelID: "m", Prompt: "replay me",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ runtime.Close()
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ if err := os.Remove(filepath.Join(workspace, ".senior-dev", "senior-dev.db") + suffix); err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ }
+ reopened, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ for table, want := range map[string]int{"session": 1, "message": 1, "part": 1} {
+ var got int
+ if err := reopened.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&got); err != nil || got != want {
+ t.Fatalf("replayed %s rows = %d, %v; want %d", table, got, err, want)
+ }
+ }
+}
+
+func TestDurableStartupReconciliationPreservesProjectionOnlyRowsContract(t *testing.T) {
+ // Startup repairs stale flat-derived rows without deleting the
+ // todo/session_message projections that flat replay cannot rebuild.
+ workspace := t.TempDir()
+ runtime := newRuntime(workspace, &capturingBackend{})
+ rootID, err := runtime.Create(context.Background(), "", "coder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_incremental", turn{
+ Agent: "coder", ProviderID: "p", ModelID: "m", Prompt: "keep projections",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runtime.durable.db.Exec(`INSERT INTO todo
+ (session_id, content, status, priority, position, time_created, time_updated)
+ VALUES (?, 'todo survives', 'pending', 'high', 0, 1, 1)`, rootID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runtime.durable.db.Exec(`INSERT INTO session_message
+ (id, session_id, type, time_created, time_updated, data)
+ VALUES ('projection-only', ?, 'note', 1, 1, '{}')`, rootID); err != nil {
+ t.Fatal(err)
+ }
+ for _, statement := range []string{
+ `UPDATE session SET title = 'stale title' WHERE id = '` + rootID + `'`,
+ `UPDATE message SET data = '{"role":"stale"}' WHERE id = 'msg_incremental'`,
+ `UPDATE part SET data = '{"type":"stale"}' WHERE message_id = 'msg_incremental'`,
+ } {
+ if _, err := runtime.durable.db.Exec(statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runtime.Close()
+
+ reopened, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ var title, messageData, partData string
+ if err := reopened.db.QueryRow("SELECT title FROM session WHERE id = ?", rootID).Scan(&title); err != nil {
+ t.Fatal(err)
+ }
+ if err := reopened.db.QueryRow("SELECT data FROM message WHERE id = 'msg_incremental'").Scan(&messageData); err != nil {
+ t.Fatal(err)
+ }
+ if err := reopened.db.QueryRow("SELECT data FROM part WHERE message_id = 'msg_incremental'").Scan(&partData); err != nil {
+ t.Fatal(err)
+ }
+ if title == "stale title" || strings.Contains(messageData, `"role":"stale"`) ||
+ strings.Contains(partData, `"type":"stale"`) {
+ t.Fatalf("stale rows remain: title=%q message=%s part=%s", title, messageData, partData)
+ }
+ for _, table := range []string{"todo", "session_message"} {
+ var count int
+ if err := reopened.db.QueryRow("SELECT COUNT(*) FROM "+table+" WHERE session_id = ?", rootID).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("projection-only %s rows = %d, %v; want 1", table, count, err)
+ }
+ }
+}
+
+func TestDurableStartupQuarantinesTruncatedJSONContract(t *testing.T) {
+ // A truncated JSON record is moved aside and cannot prevent valid durable
+ // sessions from being reconciled at startup.
+ workspace := t.TempDir()
+ runtime := newRuntime(workspace, &capturingBackend{})
+ rootID, err := runtime.Create(context.Background(), "", "coder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_valid", turn{Prompt: "valid"}); err != nil {
+ t.Fatal(err)
+ }
+ runtime.Close()
+ broken := filepath.Join(workspace, ".senior-dev", "storage", "message", rootID, "msg_truncated.json")
+ if err := os.WriteFile(broken, []byte(`{"id":"msg_truncated"`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ reopened, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatalf("startup was bricked by truncated JSON: %v", err)
+ }
+ defer reopened.Close()
+ if _, err := os.Stat(broken); !os.IsNotExist(err) {
+ t.Fatalf("truncated source was not moved aside: %v", err)
+ }
+ matches, err := filepath.Glob(filepath.Join(
+ workspace, ".senior-dev", "storage", "quarantine", "message", rootID, "msg_truncated.json.corrupt-*",
+ ))
+ if err != nil || len(matches) != 1 {
+ t.Fatalf("quarantined files = %v, %v; want one", matches, err)
+ }
+ var valid int
+ if err := reopened.db.QueryRow("SELECT COUNT(*) FROM message WHERE id = 'msg_valid'").Scan(&valid); err != nil || valid != 1 {
+ t.Fatalf("valid replay row = %d, %v; want 1", valid, err)
+ }
+}
+
+type countingProjectionSource struct {
+ projectionSource
+ reads int
+}
+
+func (source *countingProjectionSource) ReadInto(key []string, dst any) error {
+ source.reads++
+ return source.projectionSource.ReadInto(key, dst)
+}
+
+func TestDurableWarmReconciliationReadsOnlyNewRecords(t *testing.T) {
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "bounded"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ message := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: "msg_bounded", SessionID: session.ID},
+ Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder",
+ }
+ parts := make([]msgmodel.Part, 20)
+ for index := range parts {
+ parts[index] = msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{
+ ID: fmt.Sprintf("prt_%02d", index), SessionID: session.ID, MessageID: message.ID,
+ },
+ Text: "old",
+ }
+ }
+ if err := durable.UpdateMessageWithParts(context.Background(), message, parts...); err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+
+ counter := &countingProjectionSource{projectionSource: durable.store}
+ durable.projectionSource = counter
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads != 0 {
+ t.Fatalf("up-to-date warm reconciliation reads = %d, want 0", counter.reads)
+ }
+
+ newPart := msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "prt_new", SessionID: session.ID, MessageID: message.ID},
+ Text: "new",
+ }
+ if err := durable.store.Write([]string{"part", session.ID, message.ID, newPart.ID}, newPart); err != nil {
+ t.Fatal(err)
+ }
+ counter.reads = 0
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads > 3 {
+ t.Fatalf("one-record warm reconciliation reads = %d, want at most 3", counter.reads)
+ }
+ var projected int
+ if err := durable.db.QueryRow("SELECT COUNT(*) FROM part WHERE id = 'prt_new'").Scan(&projected); err != nil || projected != 1 {
+ t.Fatalf("new projected part = %d, %v; want 1", projected, err)
+ }
+ durable.Close()
+}
+
+func TestDurableReconciliationCrashBetweenLogAndProjectionMatchesRebuild(t *testing.T) {
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "crash"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalMessage := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: "msg_before_crash", SessionID: session.ID},
+ Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder",
+ }
+ originalPart := msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "prt_removed_during_crash", SessionID: session.ID, MessageID: originalMessage.ID},
+ Text: "removed before its projection event",
+ }
+ if err := durable.UpdateMessageWithParts(context.Background(), originalMessage, originalPart); err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.store.Remove([]string{"part", session.ID, originalMessage.ID, originalPart.ID}); err != nil {
+ t.Fatal(err)
+ }
+ message := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: "msg_after_crash", SessionID: session.ID},
+ Time: msgmodel.TimeCreated{Created: 10}, Agent: "coder",
+ }
+ part := msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "prt_after_crash", SessionID: session.ID, MessageID: message.ID},
+ Text: "durable before projection",
+ }
+ if err := durable.store.WriteBatch([]storage.WriteItem{
+ {Key: []string{"part", session.ID, message.ID, part.ID}, Content: part},
+ {Key: []string{"message", session.ID, message.ID}, Content: message},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ durable.Close()
+
+ recovered, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ recoveredSnapshot := projectionSnapshotJSON(t, recovered)
+ recovered.Close()
+ removeProjectionDatabase(t, workspace)
+ rebuilt, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rebuilt.Close()
+ if rebuiltSnapshot := projectionSnapshotJSON(t, rebuilt); !bytes.Equal(recoveredSnapshot, rebuiltSnapshot) {
+ t.Fatalf("recovered projection differs from full rebuild\nrecovered=%s\nrebuilt=%s", recoveredSnapshot, rebuiltSnapshot)
+ }
+}
+
+func TestDurableCorruptAndMissingMarksFallBackToFullRebuild(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ mutate func(*testing.T, *durableSessions)
+ }{
+ {name: "missing", mutate: func(t *testing.T, durable *durableSessions) {
+ _, err := durable.db.Exec("DELETE FROM senior_dev_projection_reconcile")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {name: "corrupt", mutate: func(t *testing.T, durable *durableSessions) {
+ _, err := durable.db.Exec("UPDATE senior_dev_projection_reconcile SET manifest = '{'")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {name: "older-version", mutate: func(t *testing.T, durable *durableSessions) {
+ _, err := durable.db.Exec("UPDATE senior_dev_projection_reconcile SET format_version = 0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "authoritative"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := durable.db.Exec("UPDATE session SET title = 'stale' WHERE id = ?", session.ID); err != nil {
+ t.Fatal(err)
+ }
+ test.mutate(t, durable)
+ counter := &countingProjectionSource{projectionSource: durable.store}
+ durable.projectionSource = counter
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads == 0 {
+ t.Fatal("fallback did not read the authoritative flat store")
+ }
+ var title string
+ if err := durable.db.QueryRow("SELECT title FROM session WHERE id = ?", session.ID).Scan(&title); err != nil || title != "authoritative" {
+ t.Fatalf("fallback title = %q, %v", title, err)
+ }
+ reconciledSnapshot := projectionSnapshotJSON(t, durable)
+ durable.Close()
+ removeProjectionDatabase(t, workspace)
+ rebuilt, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rebuilt.Close()
+ if got := projectionSnapshotJSON(t, rebuilt); !bytes.Equal(got, reconciledSnapshot) {
+ t.Fatalf("fallback differs from full rebuild\nfallback=%s\nrebuilt=%s", reconciledSnapshot, got)
+ }
+ })
+ }
+}
+
+func TestDurableManualProjectionDeletionInvalidatesMark(t *testing.T) {
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "restore me"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := durable.db.Exec("DELETE FROM session WHERE id = ?", session.ID); err != nil {
+ t.Fatal(err)
+ }
+ counter := &countingProjectionSource{projectionSource: durable.store}
+ durable.projectionSource = counter
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads == 0 {
+ t.Fatal("manual projection deletion did not invalidate the mark")
+ }
+ var count int
+ if err := durable.db.QueryRow("SELECT COUNT(*) FROM session WHERE id = ?", session.ID).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("restored session rows = %d, %v; want 1", count, err)
+ }
+ durable.Close()
+}
+
+func TestDurableQuarantineForcesFullFallbackAndRefreshesMark(t *testing.T) {
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "quarantine"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ broken := filepath.Join(durable.store.Dir, "message", session.ID, "msg_bad.json")
+ if err := os.MkdirAll(filepath.Dir(broken), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(broken, []byte(`{"id":"msg_bad"`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ counter := &countingProjectionSource{projectionSource: durable.store}
+ durable.projectionSource = counter
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads < 2 {
+ t.Fatalf("quarantine reconciliation reads = %d, want incremental probe plus full fallback", counter.reads)
+ }
+ if _, err := os.Stat(broken); !os.IsNotExist(err) {
+ t.Fatalf("corrupt source still exists: %v", err)
+ }
+ counter.reads = 0
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if counter.reads != 0 {
+ t.Fatalf("post-quarantine warm reads = %d, want 0", counter.reads)
+ }
+ durable.Close()
+}
+
+// projectionSnapshotJSON dumps every projector table in primary-key order so
+// two databases can be compared for identical content.
+func projectionSnapshotJSON(t testing.TB, durable *durableSessions) []byte {
+ t.Helper()
+ snapshot := map[string][]map[string]any{}
+ for _, table := range []string{"session", "message", "part", "session_message"} {
+ rows, err := durable.db.QueryContext(context.Background(), "SELECT * FROM "+table+" ORDER BY id")
+ if err != nil {
+ t.Fatal(err)
+ }
+ columns, err := rows.Columns()
+ if err != nil {
+ rows.Close()
+ t.Fatal(err)
+ }
+ images := []map[string]any{}
+ for rows.Next() {
+ values := make([]any, len(columns))
+ targets := make([]any, len(columns))
+ for index := range values {
+ targets[index] = &values[index]
+ }
+ if err := rows.Scan(targets...); err != nil {
+ rows.Close()
+ t.Fatal(err)
+ }
+ image := map[string]any{}
+ for index, column := range columns {
+ if raw, ok := values[index].([]byte); ok {
+ image[column] = string(raw)
+ } else {
+ image[column] = values[index]
+ }
+ }
+ images = append(images, image)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshot[table] = images
+ }
+ raw, err := json.Marshal(snapshot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+func removeProjectionDatabase(t testing.TB, workspace string) {
+ t.Helper()
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ if err := os.Remove(filepath.Join(workspace, ".senior-dev", "senior-dev.db") + suffix); err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkDurableProjectionReconciliation(b *testing.B) {
+ workspace := b.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ b.Fatal(err)
+ }
+ projectID := durable.projectID
+ durable.Close()
+ seedSyntheticProjectionStore(b, workspace, projectID, 100, 100, 1)
+ durable, err = openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer durable.Close()
+ counter := &countingProjectionSource{projectionSource: durable.store}
+ durable.projectionSource = counter
+
+ b.Run("cold", func(b *testing.B) {
+ counter.reads = 0
+ b.ResetTimer()
+ for range b.N {
+ if _, err := durable.db.Exec("DELETE FROM senior_dev_projection_reconcile"); err != nil {
+ b.Fatal(err)
+ }
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ b.Fatal(err)
+ }
+ }
+ b.ReportMetric(float64(counter.reads)/float64(b.N), "record-reads/op")
+ })
+ b.Run("warm", func(b *testing.B) {
+ counter.reads = 0
+ b.ResetTimer()
+ for range b.N {
+ if err := durable.reconcileProjection(context.Background()); err != nil {
+ b.Fatal(err)
+ }
+ }
+ b.ReportMetric(float64(counter.reads)/float64(b.N), "record-reads/op")
+ })
+}
+
+func seedSyntheticProjectionStore(
+ t testing.TB, workspace, projectID string, sessions, messages, parts int,
+) {
+ t.Helper()
+ root := filepath.Join(workspace, ".senior-dev", "storage")
+ for sessionIndex := range sessions {
+ sessionID := fmt.Sprintf("ses_%03d", sessionIndex)
+ info := sessioncore.Info{
+ ID: sessionID, Slug: sessionID, ProjectID: projectID, Directory: workspace,
+ Title: sessionID, Version: "test", Time: sessioncore.Time{Created: 1, Updated: 1},
+ }
+ writeSyntheticProjectionRecord(t, filepath.Join(root, "session", sessionID+".json"), info)
+ for messageIndex := range messages {
+ messageID := fmt.Sprintf("msg_%03d_%03d", sessionIndex, messageIndex)
+ message := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID},
+ Time: msgmodel.TimeCreated{Created: uint64(messageIndex + 1)}, Agent: "coder",
+ }
+ writeSyntheticProjectionRecord(t,
+ filepath.Join(root, "message", sessionID, messageID+".json"), message,
+ )
+ for partIndex := range parts {
+ partID := fmt.Sprintf("prt_%03d_%03d_%03d", sessionIndex, messageIndex, partIndex)
+ part := msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: partID, SessionID: sessionID, MessageID: messageID},
+ Text: "synthetic projection payload",
+ }
+ writeSyntheticProjectionRecord(t,
+ filepath.Join(root, "part", sessionID, messageID, partID+".json"), part,
+ )
+ }
+ }
+ }
+}
+
+func writeSyntheticProjectionRecord(t testing.TB, path string, value any) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProjectorFailureAttributedToOriginatingOperationContract(t *testing.T) {
+ // A failed projector write is returned by that mutation and cannot leak
+ // into the next operation's result.
+ workspace := t.TempDir()
+ durable, err := openDurableSessions(context.Background(), workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer durable.Close()
+ root, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "root"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := durable.db.Exec(`CREATE TRIGGER reject_bad_message BEFORE INSERT ON message
+ WHEN NEW.id = 'bad' BEGIN SELECT RAISE(ABORT, 'bad projection'); END`); err != nil {
+ t.Fatal(err)
+ }
+ message := func(id string) msgmodel.User {
+ return msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: id, SessionID: root.ID},
+ Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder",
+ }
+ }
+ if err := durable.UpdateMessage(context.Background(), message("bad")); err == nil || !strings.Contains(err.Error(), "message bad") {
+ t.Fatalf("bad projection error = %v", err)
+ }
+ if err := durable.UpdateMessage(context.Background(), message("good")); err != nil {
+ t.Fatalf("next operation inherited projector error: %v", err)
+ }
+}
+
+func TestNewPipelineCreatesAFreshRootSession(t *testing.T) {
+ // A new pipeline creates a fresh root session even when durable storage
+ // already has one from an earlier run.
+ workspace := t.TempDir()
+ first := newRuntime(workspace, &capturingBackend{})
+ t.Cleanup(first.Close)
+ oldRoot, err := first.Create(context.Background(), "", "coder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ first.Close()
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Backend: &capturingBackend{}, Events: newEventWriter(io.Discard)})
+ defer runner.runtime.Close()
+ if runner.sessionID == oldRoot {
+ t.Fatal("the new pipeline reused the newest durable root session")
+ }
+ if err := runner.runtime.ensureRootSession(context.Background(), runner.sessionID, "second run", "coder"); err != nil {
+ t.Fatal(err)
+ }
+ var roots int
+ if err := runner.runtime.durable.db.QueryRow("SELECT COUNT(*) FROM session WHERE parent_id IS NULL").Scan(&roots); err != nil || roots != 2 {
+ t.Fatalf("root sessions = %d, %v; want 2", roots, err)
+ }
+}
+
+func TestDurableHistoryPreservesInstructionDedup(t *testing.T) {
+ // Instruction dedup reads completed read-tool metadata from the durable
+ // transcript after a process restart.
+ workspace := t.TempDir()
+ nested := filepath.Join(workspace, "nested")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ rules := filepath.Join(nested, "AGENTS.md")
+ target := filepath.Join(nested, "target.txt")
+ if err := os.WriteFile(rules, []byte("DURABLE NESTED RULE"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(target, []byte("target"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ arguments, _ := json.Marshal(map[string]string{"filePath": target})
+ firstTransport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("read", string(arguments)), chatReply("first done", 10),
+ }}
+ firstRuntime := newRuntime(workspace, &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: firstTransport},
+ })
+ t.Cleanup(firstRuntime.Close)
+ rootID, err := firstRuntime.Create(context.Background(), "", "coder")
+ if err != nil {
+ firstRuntime.Close()
+ t.Fatal(err)
+ }
+ first, err := runTestTurn(t, firstRuntime, testTurn{
+ ParentSessionID: rootID, SessionTitle: "instruction session",
+ Agent: "coder", ProviderID: "openrouter",
+ ModelID: "vendor/model", Workspace: workspace, Prompt: "read once",
+ })
+ if err != nil {
+ firstRuntime.Close()
+ t.Fatal(err)
+ }
+ firstRuntime.Close()
+
+ secondTransport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("read", string(arguments)), chatReply("second done", 10),
+ }}
+ secondRuntime := newRuntime(workspace, &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: secondTransport},
+ })
+ defer secondRuntime.Close()
+ // The second runtime is a fresh process against the same session: that is
+ // what makes this a durable-transcript test rather than an in-memory one.
+ second, err := runTestTurn(t, secondRuntime, testTurn{
+ SessionID: first.SessionID, ParentSessionID: rootID,
+ SessionTitle: "instruction session", Agent: "coder", ProviderID: "openrouter",
+ ModelID: "vendor/model", Workspace: workspace, Prompt: "read again",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.SessionID != first.SessionID {
+ t.Fatalf("instruction session = %q, want %q", second.SessionID, first.SessionID)
+ }
+ messages, err := secondRuntime.durable.Messages(context.Background(), second.SessionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ loaded := []int{}
+ for _, message := range messages {
+ for _, raw := range message.Parts {
+ part, ok := raw.(msgmodel.ToolPart)
+ if !ok || part.Tool != "read" {
+ continue
+ }
+ state, ok := part.State.(msgmodel.ToolStateCompleted)
+ if !ok {
+ continue
+ }
+ field, _ := state.Metadata.Field("loaded")
+ var paths []string
+ _ = json.Unmarshal(field, &paths)
+ loaded = append(loaded, len(paths))
+ }
+ }
+ if len(loaded) != 2 || loaded[0] != 1 || loaded[1] != 0 {
+ t.Fatalf("durable instruction loaded metadata = %v, want [1 0]", loaded)
+ }
+}
+
+func countStoredJSON(root string) (int, error) {
+ count := 0
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !entry.IsDir() && filepath.Ext(path) == ".json" {
+ count++
+ }
+ return nil
+ })
+ return count, err
+}
diff --git a/internal/seniordev/app/engine_backend.go b/internal/seniordev/app/engine_backend.go
new file mode 100644
index 0000000000..c34fbf636c
--- /dev/null
+++ b/internal/seniordev/app/engine_backend.go
@@ -0,0 +1,420 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy"
+ "github.com/Agent-Field/codeaf/internal/seniordev/project"
+ systemprompt "github.com/Agent-Field/codeaf/internal/seniordev/session/system"
+)
+
+type turnToolExecutor struct{ request turn }
+
+func (backend *modelAPIBackend) Run(
+ ctx context.Context, request turn,
+) (turnResult, error) {
+ return backend.runEngine(ctx, request)
+}
+
+func (executor turnToolExecutor) Execute(
+ ctx context.Context, call steploop.ToolCall,
+) (steploop.ToolResult, error) {
+ return executeAdvertisedTool(ctx, executor.request, call)
+}
+
+func (backend *modelAPIBackend) runEngine(
+ ctx context.Context, request turn,
+) (turnResult, error) {
+ sessionID := request.SessionID
+ if sessionID == "" {
+ sessionID = steploop.NewAscendingID("ses")
+ }
+ providerID, modelID := normalizeModelRef(request.ProviderID, request.ModelID)
+ // The only way composing the system prompt fails is a turn with no agent
+ // prompt, which is a property of the request rather than of any one step,
+ // so it is rejected once here and the per-step closure below cannot fail.
+ if _, err := composeTurnSystem(ctx, request, providerID, modelID, request.SystemInstructions); err != nil {
+ return turnResult{SessionID: sessionID}, err
+ }
+ system := func(callCtx context.Context) string {
+ // Root instructions are resolved again on every step.
+ instructions := request.SystemInstructions
+ if request.LoadInstructions != nil {
+ instructions = request.LoadInstructions(callCtx)
+ }
+ text, _ := composeTurnSystem(callCtx, request, providerID, modelID, instructions)
+ return text
+ }
+
+ store := request.Store
+ if store == nil {
+ store = newTurnStore()
+ }
+ variant := request.Variant
+ if variant == "" {
+ variant = backend.variant
+ }
+ startMessageID := request.PromptMessageID
+ if !request.PromptPersisted {
+ var seedErr error
+ startMessageID, seedErr = seedTurn(
+ ctx, store, sessionID, request, providerID, modelID, variant,
+ )
+ if seedErr != nil {
+ return turnResult{SessionID: sessionID}, seedErr
+ }
+ }
+ if startMessageID == "" {
+ return turnResult{SessionID: sessionID}, errors.New("senior-dev engine: prompt message is required")
+ }
+ ledger := &turnLedger{}
+ models := seniorDevModels{
+ backend: backend, sessionID: sessionID, agent: request.Agent, variant: variant,
+ }
+ client := newSeniorDevLLM(
+ backend, sessionID, providerID, modelID, request.Agent, variant, system, ledger,
+ request.RawModelCall,
+ )
+ client.modelRequests = request.ModelRequests
+ tasks := newSeniorDevCompactionController(
+ store, seniorDevSummaryClient{owner: client}, models, request.Workspace,
+ backend, system, request.Tools, request.CompactionDecisions, sessionID,
+ )
+ loop := steploop.Loop{
+ Store: store, Client: client, Models: models,
+ Executor: turnToolExecutor{request: request}, Tasks: tasks,
+ }
+ assistant, runErr := loop.Run(ctx, steploop.RunOptions{
+ SessionID: sessionID, ParentID: request.ParentSessionID,
+ Workspace: request.Workspace, Worktree: request.Workspace,
+ MaxSteps: request.MaxSteps,
+ Tools: request.Tools,
+ InjectReminders: turnReminderInjector(store, sessionID, request.BetweenStepReminder),
+ AfterAssistant: request.AfterAssistant,
+ })
+ messages, messagesErr := store.Messages(ctx, sessionID)
+ result := projectTurnResult(
+ sessionID, messagesSince(messages, startMessageID), ledger.snapshot(),
+ )
+ if assistant.Finish != nil {
+ result.FinishReason = *assistant.Finish
+ }
+ if runErr != nil {
+ return result, runErr
+ }
+ if messagesErr != nil {
+ return result, messagesErr
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, ctxErr
+ }
+ if assistant.Error != nil {
+ return result, turnAssistantError(assistant.Error)
+ }
+ return result, nil
+}
+
+func composeTurnSystem(
+ ctx context.Context,
+ request turn,
+ providerID string,
+ modelID string,
+ instructions []string,
+) (string, error) {
+ if request.RawModelCall {
+ return "", nil
+ }
+ model := systemprompt.Model{ProviderID: providerID, API: systemprompt.API{ID: modelID}}
+ // The agent prompt is the whole role: there is no model-family base prompt
+ // behind it, so a turn without one has nothing to say and is refused.
+ agentPrompt := request.AgentMarkdown
+ if !request.AgentPromptVerbatim {
+ agentPrompt = baked.PromptContent(agentPrompt)
+ }
+ if strings.TrimSpace(agentPrompt) == "" {
+ return "", fmt.Errorf("senior-dev engine: agent %q has no system prompt", request.Agent)
+ }
+ parts := []string{agentPrompt}
+ service := systemprompt.New(turnSystemContext(ctx, request.Workspace))
+ parts = append(parts, service.Environment(model)...)
+ // Restricted runs say so up front, so agents plan around the missing
+ // network instead of discovering it one failed command at a time.
+ parts = append(parts, netpolicy.Current().EnvironmentNotice())
+ parts = append(parts, instructions...)
+ return strings.Join(nonEmpty(parts...), "\n"), nil
+}
+
+func turnSystemContext(ctx context.Context, workspace string) systemprompt.Context {
+ directory, worktree, vcs := workspace, workspace, ""
+ if instance, ok := project.FromContext(ctx); ok {
+ if instance.Directory != "" {
+ directory = instance.Directory
+ }
+ if instance.Worktree != "" {
+ worktree = instance.Worktree
+ }
+ if instance.Project.VCS != nil {
+ vcs = *instance.Project.VCS
+ }
+ }
+ if vcs == "" && directory != "" {
+ command := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--is-inside-work-tree")
+ if output, err := command.Output(); err == nil && strings.TrimSpace(string(output)) == "true" {
+ vcs = "git"
+ }
+ }
+ return systemprompt.Context{
+ Directory: directory, Worktree: worktree,
+ Project: systemprompt.Project{VCS: vcs},
+ }
+}
+
+func nonEmpty(values ...string) []string {
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ out = append(out, value)
+ }
+ }
+ return out
+}
+
+func seedTurn(
+ ctx context.Context,
+ store steploop.Store,
+ sessionID string,
+ request turn,
+ providerID string,
+ modelID string,
+ variant string,
+) (string, error) {
+ now := uint64(time.Now().UnixMilli())
+ messageID := request.MessageID
+ if messageID == "" {
+ messageID = steploop.NewAscendingID("msg")
+ }
+ user := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID},
+ Time: msgmodel.TimeCreated{Created: now},
+ Agent: request.Agent,
+ Model: msgmodel.UserModel{
+ ProviderID: providerID, ModelID: modelID,
+ },
+ }
+ if variant != "" {
+ user.Model.Variant = &variant
+ }
+ if err := store.UpdateMessage(ctx, user); err != nil {
+ return "", err
+ }
+ if err := store.UpdatePart(ctx, msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{
+ ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID,
+ },
+ Text: request.Prompt,
+ }); err != nil {
+ return "", err
+ }
+ return messageID, nil
+}
+
+func messagesSince(messages []msgmodel.WithParts, messageID string) []msgmodel.WithParts {
+ for index, message := range messages {
+ if message.Info.MessageID() == messageID {
+ return messages[index:]
+ }
+ }
+ return messages
+}
+
+func turnReminderInjector(
+ store steploop.Store,
+ sessionID string,
+ next func() string,
+) func(context.Context, []msgmodel.WithParts, msgmodel.User) ([]msgmodel.WithParts, error) {
+ if next == nil {
+ return nil
+ }
+ return func(
+ ctx context.Context, messages []msgmodel.WithParts, user msgmodel.User,
+ ) ([]msgmodel.WithParts, error) {
+ text := next()
+ if text == "" {
+ return messages, nil
+ }
+ messageID := steploop.NewAscendingID("msg")
+ reminder := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID},
+ Time: msgmodel.TimeCreated{Created: uint64(time.Now().UnixMilli())},
+ Agent: user.Agent, Model: user.Model,
+ }
+ part := msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{
+ ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID,
+ },
+ Text: text, Synthetic: boolPointer(true),
+ }
+ if err := store.UpdateMessage(ctx, reminder); err != nil {
+ return nil, err
+ }
+ if err := store.UpdatePart(ctx, part); err != nil {
+ return nil, err
+ }
+ out := append([]msgmodel.WithParts(nil), messages...)
+ return append(out, msgmodel.WithParts{Info: reminder, Parts: msgmodel.Parts{part}}), nil
+ }
+}
+
+func boolPointer(value bool) *bool { return &value }
+
+func projectTurnResult(
+ sessionID string, messages []msgmodel.WithParts, calls []turnCall,
+) turnResult {
+ result := turnResult{SessionID: sessionID}
+ for _, call := range calls {
+ result.CostUSD += call.CostUSD
+ }
+
+ lastSummary := -1
+ summaryText := ""
+ callIndex := 0
+ messageCalls := make([]turnCall, 0, len(calls))
+ for _, call := range calls {
+ if !call.Detached {
+ messageCalls = append(messageCalls, call)
+ }
+ }
+ assistantCalls := make(map[int]turnCall)
+ for index, message := range messages {
+ assistant, ok := message.Info.(msgmodel.Assistant)
+ if !ok {
+ continue
+ }
+ call := turnCall{}
+ if callIndex < len(messageCalls) {
+ call = messageCalls[callIndex]
+ }
+ assistantCalls[index] = call
+ callIndex++
+ if assistant.Summary != nil && *assistant.Summary &&
+ assistant.Finish != nil && *assistant.Finish != "" && assistant.Error == nil {
+ lastSummary = index
+ summaryText = messageText(message)
+ }
+ }
+ if lastSummary >= 0 {
+ result.Parts = append(result.Parts, turnPart{
+ Type: "compaction", Text: summaryText,
+ })
+ }
+
+ pendingActionCost := 0.0
+ for index, message := range messages {
+ assistant, ok := message.Info.(msgmodel.Assistant)
+ if !ok {
+ continue
+ }
+ call := assistantCalls[index]
+ if index <= lastSummary || (assistant.Summary != nil && *assistant.Summary) {
+ pendingActionCost += call.CostUSD
+ continue
+ }
+ pendingActionCost += call.CostUSD
+ charged := false
+ for _, raw := range message.Parts {
+ switch part := raw.(type) {
+ case msgmodel.TextPart:
+ if part.Text != "" {
+ result.Parts = append(result.Parts, turnPart{
+ Type: "text", Text: part.Text,
+ })
+ result.Text = part.Text
+ }
+ case msgmodel.ToolPart:
+ status := "pending"
+ args := "{}"
+ if part.State != nil {
+ status = part.State.ToolStatus()
+ args = string(part.State.ToolInput().Value())
+ }
+ toolPart := turnPart{
+ Type: "tool", Tool: part.Tool, ArgsKey: args, Status: status,
+ }
+ if !charged && pendingActionCost != 0 {
+ cost := pendingActionCost
+ toolPart.CostUSD = &cost
+ pendingActionCost = 0
+ charged = true
+ }
+ result.Parts = append(result.Parts, toolPart)
+ }
+ }
+ }
+ return result
+}
+
+func messageText(message msgmodel.WithParts) string {
+ var lines []string
+ for _, raw := range message.Parts {
+ if part, ok := raw.(msgmodel.TextPart); ok && strings.TrimSpace(part.Text) != "" {
+ lines = append(lines, part.Text)
+ }
+ }
+ return strings.TrimSpace(strings.Join(lines, "\n"))
+}
+
+type modelTurnError struct {
+ kind string
+ message string
+ statusCode *uint64
+ retryable bool
+ responseBody string
+}
+
+func (err *modelTurnError) Error() string {
+ if err.message != "" {
+ return err.message
+ }
+ if err.kind != "" {
+ return err.kind
+ }
+ return "senior-dev: model turn failed"
+}
+
+func turnAssistantError(value *msgmodel.AssistantError) error {
+ if value == nil {
+ return nil
+ }
+ failure := &modelTurnError{kind: value.Name}
+ if value.Name == msgmodel.ErrNameAPI {
+ var data msgmodel.APIError
+ if json.Unmarshal(value.Data, &data) == nil {
+ failure.message = data.Message
+ failure.statusCode = data.StatusCode
+ failure.retryable = data.IsRetryable
+ if data.ResponseBody != nil {
+ failure.responseBody = *data.ResponseBody
+ }
+ }
+ }
+ var data struct {
+ Message string `json:"message"`
+ }
+ if failure.message == "" && json.Unmarshal(value.Data, &data) == nil {
+ failure.message = data.Message
+ }
+ return failure
+}
+
+var _ steploop.ToolExecutor = turnToolExecutor{}
diff --git a/internal/seniordev/app/engine_client.go b/internal/seniordev/app/engine_client.go
new file mode 100644
index 0000000000..0146b84949
--- /dev/null
+++ b/internal/seniordev/app/engine_client.go
@@ -0,0 +1,503 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "runtime"
+ "strings"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/llmcall"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+type seniorDevModels struct {
+ backend *modelAPIBackend
+ sessionID string
+ agent string
+ variant string
+}
+
+func (models seniorDevModels) GetModel(
+ _ context.Context, providerID, modelID string,
+) (llmcall.Model, error) {
+ providerID, modelID = normalizeModelRef(providerID, modelID)
+ projection, _, err := models.projection(providerID, modelID)
+ if err != nil {
+ return llmcall.Model{}, err
+ }
+ maximum := orclient.MaxOutputTokens(projection)
+ options := orclient.Options(orclient.OptionsInput{
+ Model: projection, SessionID: models.sessionID,
+ })
+ options = orclient.MergeOptions(options, models.backend.config.options(models.agent, providerID, modelID))
+ // NO ROUTING PREFERENCE LEAVES THIS PROGRAM. Which upstream serves a call
+ // is codeaf's model funnel's to decide — its router, its retries and its
+ // endpoint pins — so a `provider` block a config file spelled into the
+ // options is dropped here rather than sent for the API to trip over.
+ options = options.Without("provider")
+ effort := models.variant
+ if effort == "" {
+ effort = models.backend.variant
+ }
+ if effort != "" {
+ reasoning := orclient.NewObject()
+ reasoning.SetString("effort", effort)
+ options.SetObject("reasoning", reasoning)
+ }
+ params := orclient.RequestParams{
+ MaxOutputTokens: &maximum,
+ OpenRouterOptions: options,
+ Compatibility: orclient.CompatibilityCompatible,
+ }
+ return llmcall.Model{
+ ProviderID: providerID,
+ ID: modelID,
+ APIID: modelID,
+ Params: params,
+ }, nil
+}
+
+func (models seniorDevModels) Resolve(
+ ctx context.Context, user msgmodel.User,
+) (steploop.Model, error) {
+ resolved, err := models.GetModel(ctx, user.Model.ProviderID, user.Model.ModelID)
+ if err != nil {
+ return steploop.Model{}, err
+ }
+ projection, metadata, err := models.projection(resolved.ProviderID, resolved.ID)
+ if err != nil {
+ return steploop.Model{}, err
+ }
+ return steploop.Model{
+ Message: msgmodel.Model{
+ ProviderID: resolved.ProviderID,
+ ID: resolved.ID,
+ API: msgmodel.ModelAPI{
+ Npm: projection.API.Npm, ID: projection.API.ID,
+ },
+ },
+ Calc: metadata,
+ Request: resolved.Params,
+ }, nil
+}
+
+func (models seniorDevModels) projection(
+ providerID, modelID string,
+) (orclient.Model, calc.Model, error) {
+ metadata, err := models.catalogModel(providerID, modelID)
+ if err != nil {
+ return orclient.Model{}, calc.Model{}, err
+ }
+ configured := models.backend.config.model(providerID, modelID)
+ limits := objectValue(configured["limit"])
+ if value, ok := configNumber(limits["context"]); ok {
+ metadata.Limit.Context = value
+ }
+ if value, ok := configNumber(limits["output"]); ok {
+ metadata.Limit.Output = value
+ }
+ if value, ok := configNumber(limits["input"]); ok {
+ metadata.Limit.Input = &value
+ }
+ if models.backend.contextLimit != 0 {
+ metadata.Limit.Context = models.backend.contextLimit
+ }
+ if models.backend.outputLimit != 0 {
+ metadata.Limit.Output = models.backend.outputLimit
+ }
+ if metadata.Cost == nil {
+ metadata.Cost = &calc.ModelCost{Cache: &calc.CacheCost{}}
+ }
+ if metadata.Cost.Cache == nil {
+ metadata.Cost.Cache = &calc.CacheCost{}
+ }
+ cost := objectValue(configured["cost"])
+ if value, ok := configNumber(cost["input"]); ok {
+ metadata.Cost.Input = value
+ }
+ if value, ok := configNumber(cost["output"]); ok {
+ metadata.Cost.Output = value
+ }
+ if value, ok := configNumber(cost["cache_read"]); ok {
+ metadata.Cost.Cache.Read = value
+ }
+ if value, ok := configNumber(cost["cache_write"]); ok {
+ metadata.Cost.Cache.Write = value
+ }
+ projection := orclient.Model{
+ ProviderID: providerID,
+ ID: modelID,
+ API: orclient.ModelAPI{
+ Npm: "@openrouter/ai-sdk-provider", ID: modelID,
+ },
+ Capabilities: orclient.ModelCapabilities{
+ Temperature: metadata.Capabilities.Temperature,
+ Reasoning: metadata.Capabilities.Reasoning,
+ Attachment: metadata.Capabilities.Attachment,
+ ToolCall: metadata.Capabilities.ToolCall,
+ Input: metadata.Capabilities.Input,
+ Output: metadata.Capabilities.Output,
+ },
+ Limit: orclient.ModelLimit{
+ Context: metadata.Limit.Context,
+ Input: metadata.Limit.Input,
+ Output: metadata.Limit.Output,
+ },
+ }
+ return projection, metadata, nil
+}
+
+func (models seniorDevModels) catalogModel(providerID, modelID string) (calc.Model, error) {
+ if models.backend.catalog != nil {
+ metadata, err := models.backend.catalog.Resolve(providerID, modelID)
+ if err == nil {
+ return metadata, nil
+ }
+ if len(models.backend.config.model(providerID, modelID)) == 0 {
+ return calc.Model{}, err
+ }
+ // A config-defined model absent from models.dev gets zero cost and zero
+ // context/output limits unless the config block supplies them.
+ return calc.Model{
+ Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
+ // Unknown capabilities are permissive.
+ Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
+ }, nil
+ }
+ // A nil catalog is an explicit seam for injected engine tests. Every
+ // shipped CLI backend receives a loaded (possibly disabled/empty) catalog.
+ return calc.Model{
+ Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
+ // Unknown capabilities are permissive.
+ Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
+ }, nil
+}
+
+// normalizeModelRef files a model under the service codeaf's model API speaks
+// for (orclient.Service) when it names none, and takes that service's own
+// prefix off the model's id, which is how the API is asked for it.
+func normalizeModelRef(providerID, modelID string) (string, string) {
+ if providerID == "" {
+ if before, after, ok := strings.Cut(modelID, "/"); ok && before == orclient.Service {
+ providerID, modelID = before, after
+ }
+ }
+ if providerID == "" {
+ providerID = orclient.Service
+ }
+ if providerID == orclient.Service {
+ modelID = strings.TrimPrefix(modelID, orclient.Service+"/")
+ }
+ return providerID, modelID
+}
+
+type seniorDevClientFactory struct {
+ backend *modelAPIBackend
+ sessionID string
+ models seniorDevModels
+ ledger *turnLedger
+ agent string
+ bypassToolFilter bool
+}
+
+func (factory seniorDevClientFactory) Client(
+ _ context.Context,
+ model llmcall.Model,
+ choice *adaptive.RouteChoice,
+ router *adaptive.AdaptiveModelRouter,
+) (llmcall.StreamClient, error) {
+ projection, _, err := factory.models.projection(model.ProviderID, model.ID)
+ if err != nil {
+ return nil, err
+ }
+ factory.ledger.setModel(model.ProviderID + "/" + model.ID)
+ client := &orclient.Client{
+ BaseURL: factory.backend.api.BaseURL,
+ Headers: seniorDevHeaders(
+ factory.sessionID, factory.backend.config.headers(model.ProviderID, model.ID),
+ ),
+ Compatibility: orclient.CompatibilityCompatible,
+ Router: router,
+ RouteChoice: choice,
+ TotalTimeoutMS: factory.backend.totalTimeoutMS,
+ ChunkTimeoutMS: factory.backend.chunkTimeoutMS,
+ // The one door (runtime.go's fetch): the model API's token goes on
+ // every request here, over the backend's one HTTP client.
+ Fetcher: factory.backend.fetch,
+ }
+ return seniorDevStreamClient{
+ client: client, model: projection, agent: factory.agent,
+ bypassToolFilter: factory.bypassToolFilter,
+ backend: factory.backend, sessionID: factory.sessionID,
+ }, nil
+}
+
+type seniorDevStreamClient struct {
+ backend *modelAPIBackend
+ sessionID string
+ client *orclient.Client
+ model orclient.Model
+ agent string
+ bypassToolFilter bool
+}
+
+func (client seniorDevStreamClient) DoStream(
+ ctx context.Context, params orclient.RequestParams,
+) (llmcall.Stream, error) {
+ params.Prompt = orclient.Message(params.Prompt, client.model)
+ params.Tools = client.visibleTools(params.Tools)
+ stream, err := client.client.DoStream(ctx, params)
+ if err != nil {
+ // A context-length rejection is how a smaller-than-advertised
+ // endpoint announces itself; under the window policy it pins the
+ // session's capacity (compaction_pin.go). The error itself is
+ // unchanged: the step loop still turns it into a compaction.
+ client.backend.pinCapacityOnOverflow(
+ client.sessionID, client.agent, client.model.ProviderID, client.model.ID, err,
+ )
+ return nil, err
+ }
+ return stream, nil
+}
+
+func (client seniorDevStreamClient) visibleTools(tools []orclient.Tool) []orclient.Tool {
+ if client.bypassToolFilter {
+ return tools
+ }
+ definitions := make([]steploop.ToolDefinition, 0, len(tools))
+ for _, provider := range tools {
+ definitions = append(definitions, steploop.ToolDefinition{Provider: provider})
+ }
+ filtered := tool.FilterDefinitions(definitions, tool.FilterInput{
+ ProviderID: client.model.ProviderID,
+ ModelID: client.model.ID,
+ Flags: tool.CurrentWebSearchFlags(),
+ })
+ out := make([]orclient.Tool, 0, len(filtered))
+ for _, definition := range filtered {
+ out = append(out, definition.Provider)
+ }
+ return out
+}
+
+// seniorDevHeaders are the headers of one model request: any a config file
+// named, the session affinity that keeps one conversation on one warm cache,
+// and the composed user agent. The token is not among them; fetch sets it on
+// the way out, over whatever these say. Nor are a service's attribution
+// headers: the call is codeaf's to make and to attribute.
+func seniorDevHeaders(sessionID string, configured []orclient.HeaderPair) []orclient.HeaderPair {
+ return orclient.BuildHeaders(orclient.HeaderInputs{
+ Provider: configured,
+ ProviderUserAgentSuffix: "ai-sdk/openrouter/2.8.1",
+ Call: []orclient.HeaderPair{
+ {Name: "x-session-affinity", Value: sessionID},
+ },
+ UtilsUserAgentSuffix: "ai-sdk/provider-utils/4.0.23",
+ RuntimeUserAgentSuffix: "runtime/" + runtime.Version(),
+ })
+}
+
+type turnCall struct {
+ Summary bool
+ Detached bool
+ CostUSD float64
+ ModelID string
+}
+
+type turnLedger struct {
+ mu sync.Mutex
+ calls []*turnCall
+}
+
+func (ledger *turnLedger) begin(summary bool) *turnCall {
+ return ledger.beginCall(summary, false)
+}
+
+func (ledger *turnLedger) beginCall(summary, detached bool) *turnCall {
+ call := &turnCall{Summary: summary, Detached: detached}
+ ledger.mu.Lock()
+ ledger.calls = append(ledger.calls, call)
+ ledger.mu.Unlock()
+ return call
+}
+
+func (ledger *turnLedger) addCost(call *turnCall, cost float64) {
+ ledger.mu.Lock()
+ call.CostUSD += cost
+ ledger.mu.Unlock()
+}
+
+func (ledger *turnLedger) setModel(modelID string) {
+ ledger.mu.Lock()
+ if len(ledger.calls) > 0 {
+ ledger.calls[len(ledger.calls)-1].ModelID = modelID
+ }
+ ledger.mu.Unlock()
+}
+
+func (ledger *turnLedger) snapshot() []turnCall {
+ ledger.mu.Lock()
+ defer ledger.mu.Unlock()
+ out := make([]turnCall, 0, len(ledger.calls))
+ for _, call := range ledger.calls {
+ out = append(out, *call)
+ }
+ return out
+}
+
+type seniorDevLLM struct {
+ backend *modelAPIBackend
+ models seniorDevModels
+ service *llmcall.Service
+ ledger *turnLedger
+ sessionID string
+ providerID string
+ modelID string
+ agent string
+ system func(context.Context) string
+ modelRequests modelRequestSink
+}
+
+func newSeniorDevLLM(
+ backend *modelAPIBackend,
+ sessionID, providerID, modelID, agent, variant string,
+ system func(context.Context) string,
+ ledger *turnLedger,
+ bypassToolFilter bool,
+) *seniorDevLLM {
+ models := seniorDevModels{
+ backend: backend, sessionID: sessionID, agent: agent, variant: variant,
+ }
+ factory := seniorDevClientFactory{
+ backend: backend, sessionID: sessionID, models: models, ledger: ledger,
+ agent: agent, bypassToolFilter: bypassToolFilter,
+ }
+ return &seniorDevLLM{
+ backend: backend, models: models, ledger: ledger,
+ service: &llmcall.Service{
+ Models: models, Clients: factory, Router: backend.router,
+ DisableRouting: backend.router == nil,
+ },
+ sessionID: sessionID, providerID: providerID, modelID: modelID,
+ agent: agent, system: system,
+ }
+}
+
+func (client *seniorDevLLM) Stream(
+ ctx context.Context, params orclient.RequestParams,
+) (steploop.PartStream, error) {
+ system := ""
+ if client.system != nil {
+ system = client.system(ctx)
+ }
+ return client.stream(ctx, params, client.agent, system, false)
+}
+
+type seniorDevSummaryClient struct{ owner *seniorDevLLM }
+
+func (client seniorDevSummaryClient) Stream(
+ ctx context.Context, params orclient.RequestParams,
+) (steploop.PartStream, error) {
+ return client.owner.stream(
+ ctx, params, "compaction", compaction.SummarySystemPrompt, true,
+ )
+}
+
+func (client *seniorDevLLM) stream(
+ ctx context.Context,
+ params orclient.RequestParams,
+ agent string,
+ system string,
+ summary bool,
+) (steploop.PartStream, error) {
+ call := client.ledger.begin(summary)
+ return client.streamAttempt(ctx, params, agent, system, call)
+}
+
+func (client *seniorDevLLM) streamAttempt(
+ ctx context.Context,
+ params orclient.RequestParams,
+ agent string,
+ system string,
+ call *turnCall,
+) (steploop.PartStream, error) {
+ providerID, modelID := normalizeModelRef(client.providerID, params.ModelID)
+ if modelID == "" {
+ modelID = client.modelID
+ }
+ observation := beginModelRequest(ctx, client.modelRequests, client.sessionID, agent, providerID, modelID)
+ model, err := client.models.GetModel(ctx, providerID, modelID)
+ if err != nil {
+ observation.finish("resolve", err)
+ return nil, err
+ }
+ systems := []string{}
+ if system != "" {
+ systems = append(systems, system)
+ }
+ stream, err := client.service.Stream(ctx, llmcall.StreamInput{
+ SessionID: client.sessionID,
+ Model: model,
+ Agent: llmcall.Agent{
+ Name: agent, Mode: agent,
+ Tier: adaptive.ModelTier(baked.TierFor(agent)),
+ },
+ System: systems, Messages: params.Prompt,
+ Tools: params.Tools, ToolChoice: params.ToolChoice,
+ })
+ if err != nil {
+ observation.finish("begin", err)
+ return nil, err
+ }
+ return &costPartStream{inner: stream, ledger: client.ledger, call: call, observation: observation}, nil
+}
+
+type costPartStream struct {
+ inner llmcall.Stream
+ ledger *turnLedger
+ call *turnCall
+ observation *modelRequestObservation
+}
+
+func (stream *costPartStream) Next() (orclient.StreamPart, error) {
+ part, err := stream.inner.Next()
+ stream.observation.observe(part, err)
+ if finish, ok := part.(orclient.FinishPart); ok {
+ stream.ledger.addCost(stream.call, finishCost(finish))
+ }
+ return part, err
+}
+
+func (stream *costPartStream) Close() error {
+ err := stream.inner.Close()
+ stream.observation.finish("close", err)
+ return err
+}
+
+func finishCost(finish orclient.FinishPart) float64 {
+ raw, ok := finish.Metadata.Usage.Get("cost")
+ if !ok {
+ return 0
+ }
+ var cost float64
+ if json.Unmarshal(raw, &cost) != nil {
+ return 0
+ }
+ return cost
+}
+
+var _ llmcall.ModelResolver = seniorDevModels{}
+var _ steploop.ModelResolver = seniorDevModels{}
+var _ llmcall.ClientFactory = seniorDevClientFactory{}
+var _ steploop.LLMClient = (*seniorDevLLM)(nil)
+var _ steploop.LLMClient = seniorDevSummaryClient{}
diff --git a/internal/seniordev/app/engine_compaction.go b/internal/seniordev/app/engine_compaction.go
new file mode 100644
index 0000000000..866d28982c
--- /dev/null
+++ b/internal/seniordev/app/engine_compaction.go
@@ -0,0 +1,236 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os/exec"
+ "strings"
+ "unicode/utf16"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+ "github.com/Agent-Field/codeaf/internal/seniordev/util"
+)
+
+type seniorDevCompactionModels struct {
+ resolver steploop.ModelResolver
+}
+
+func (models seniorDevCompactionModels) GetModel(
+ ctx context.Context, providerID, modelID string,
+) (compaction.Model, error) {
+ resolved, err := models.resolver.Resolve(ctx, msgmodel.User{
+ Model: msgmodel.UserModel{ProviderID: providerID, ModelID: modelID},
+ })
+ if err != nil {
+ return compaction.Model{}, err
+ }
+ return compaction.Model{Message: resolved.Message, Overflow: resolved.Calc}, nil
+}
+
+func (seniorDevCompactionModels) GetProvider(
+ context.Context, string,
+) (compaction.ProviderInfo, error) {
+ return compaction.ProviderInfo{}, nil
+}
+
+type seniorDevSummaryFactory struct {
+ store steploop.Store
+ client steploop.LLMClient
+}
+
+func (factory seniorDevSummaryFactory) Create(
+ _ context.Context,
+ assistant *msgmodel.Assistant,
+ _ string,
+ model compaction.Model,
+) (compaction.SummaryProcessor, error) {
+ stepModel := steploop.Model{Message: model.Message, Calc: model.Overflow}
+ return &seniorDevSummaryProcessor{
+ processor: steploop.NewProcessor(steploop.ProcessorOptions{
+ Store: factory.store, Assistant: *assistant, Model: stepModel,
+ }),
+ client: factory.client,
+ model: stepModel,
+ }, nil
+}
+
+type seniorDevSummaryProcessor struct {
+ processor *steploop.Processor
+ client steploop.LLMClient
+ model steploop.Model
+}
+
+func (processor *seniorDevSummaryProcessor) Process(
+ ctx context.Context, request compaction.SummaryRequest,
+) (steploop.Result, error) {
+ params := processor.model.Request
+ params.ModelID = processor.model.Message.ID
+ params.Prompt = request.Messages
+ params.Tools = nil
+ params.ToolChoice = nil
+ if params.MaxOutputTokens == nil {
+ maximum := calc.MaxOutputTokens(processor.model.Calc)
+ params.MaxOutputTokens = &maximum
+ }
+ stream, err := processor.client.Stream(ctx, params)
+ if err != nil {
+ stream = &steploop.SliceStream{Failure: err}
+ }
+ return processor.processor.Process(ctx, stream)
+}
+
+func (processor *seniorDevSummaryProcessor) Message() msgmodel.Assistant {
+ return processor.processor.Message()
+}
+
+type seniorDevContextSizer struct {
+ system func(context.Context) string
+ tools []steploop.ToolDefinition
+}
+
+func (sizer seniorDevContextSizer) EstimateContext(
+ ctx context.Context, messages []msgmodel.WithParts, model compaction.Model,
+) (float64, error) {
+ projected, err := msgmodel.ToModelMessages(messages, model.Message, nil)
+ if err != nil {
+ return 0, err
+ }
+ tools := make([]orclient.Tool, 0, len(sizer.tools))
+ for _, definition := range sizer.tools {
+ tools = append(tools, definition.Provider)
+ }
+ system := ""
+ if sizer.system != nil {
+ system = sizer.system(ctx)
+ }
+ raw, err := json.Marshal(struct {
+ System string `json:"system,omitempty"`
+ Messages []msgmodel.ModelMessage `json:"messages"`
+ Tools []orclient.Tool `json:"tools,omitempty"`
+ }{System: system, Messages: projected, Tools: tools})
+ if err != nil {
+ return 0, err
+ }
+ units := len(utf16.Encode([]rune(string(raw))))
+ return math.Ceil(float64(units) / 4), nil
+}
+
+func newSeniorDevCompactionController(
+ store steploop.Store,
+ summaryClient steploop.LLMClient,
+ resolver steploop.ModelResolver,
+ workspace string,
+ backend *modelAPIBackend,
+ system func(context.Context) string,
+ tools []steploop.ToolDefinition,
+ decisions compaction.DecisionSink,
+ sessionID string,
+) compaction.Controller {
+ service := compaction.NewService(compaction.Dependencies{
+ Store: store,
+ // The session's config, not the project's: under the window policy
+ // a context-overflow rejection may have pinned this session's
+ // capacity below the window (compaction_pin.go).
+ Config: compaction.ConfigProviderFunc(func(context.Context) (overflow.Config, error) {
+ return backend.overflowConfigFor(sessionID)
+ }),
+ Agents: compaction.AgentProviderFunc(func(
+ context.Context, string,
+ ) (compaction.Agent, error) {
+ return compaction.Agent{Name: "compaction"}, nil
+ }),
+ Provider: seniorDevCompactionModels{resolver: resolver},
+ Processors: seniorDevSummaryFactory{
+ store: store, client: summaryClient,
+ },
+ // Evidence is harvested deterministically by code: no second model call
+ // is made per compaction boundary.
+ Evidence: compaction.FallbackEvidenceSelector{},
+ Sizer: seniorDevContextSizer{system: system, tools: tools},
+ Decisions: decisions,
+ Instance: compaction.InstanceContext{Directory: workspace, Worktree: workspace},
+ ChangedFiles: func(ctx context.Context) []string {
+ return seniorDevChangedFiles(ctx, workspace)
+ },
+ NewID: func(prefix string) string {
+ if prefix == "message" {
+ prefix = "msg"
+ } else if prefix == "part" {
+ prefix = "prt"
+ }
+ return steploop.NewAscendingID(prefix)
+ },
+ })
+ return compaction.Controller{Compaction: service}
+}
+
+// soloStartRef names the run's exact starting tree, written by the solo
+// pipeline when the run begins (solo_finalize.go). It is what the changed-files
+// record diffs against.
+const soloStartRef = "refs/senior-dev/start"
+
+const changedFilesMaxLines = 40
+
+// seniorDevChangedFiles computes the changed-files record pinned beside every
+// compaction summary: a diffstat of the working tree against the starting
+// tree (tracked files, which eager-commit makes every file the model writes)
+// plus the short status (untracked files, in-progress edits). Read-only, no
+// diff contents, hard line cap. An unavailable git answers with nothing.
+func seniorDevChangedFiles(ctx context.Context, workspace string) []string {
+ if workspace == "" {
+ return nil
+ }
+ git := func(args ...string) ([]string, bool) {
+ argv := util.GitArgv(args...)
+ command := exec.CommandContext(ctx, argv[0], argv[1:]...)
+ command.Dir = workspace
+ out, err := command.Output()
+ if err != nil {
+ return nil, false
+ }
+ lines := []string{}
+ for _, line := range strings.Split(string(out), "\n") {
+ if strings.TrimSpace(line) != "" {
+ lines = append(lines, strings.TrimRight(line, " \t"))
+ }
+ }
+ return lines, true
+ }
+ var record []string
+ if _, ok := git("rev-parse", "--verify", "--quiet", soloStartRef+"^{commit}"); ok {
+ if stat, ok := git("diff", "--stat=100", soloStartRef, "--"); ok {
+ record = append(record, "Against the starting tree (git diff --stat "+soloStartRef+"):")
+ if len(stat) == 0 {
+ record = append(record, " (no tracked file differs from the starting tree)")
+ }
+ record = append(record, capLines(stat, changedFilesMaxLines)...)
+ }
+ }
+ if status, ok := git("status", "--short"); ok {
+ record = append(record, "Working tree status (git status --short):")
+ if len(status) == 0 {
+ record = append(record, " (clean)")
+ }
+ record = append(record, capLines(status, changedFilesMaxLines)...)
+ }
+ return record
+}
+
+func capLines(lines []string, maximum int) []string {
+ if len(lines) <= maximum {
+ return lines
+ }
+ return append(append([]string{}, lines[:maximum]...),
+ fmt.Sprintf(" ... %d more lines", len(lines)-maximum))
+}
+
+var _ steploop.TaskController = compaction.Controller{}
diff --git a/internal/seniordev/app/engine_compaction_test.go b/internal/seniordev/app/engine_compaction_test.go
new file mode 100644
index 0000000000..1509a062b1
--- /dev/null
+++ b/internal/seniordev/app/engine_compaction_test.go
@@ -0,0 +1,239 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+)
+
+// recordingClient stands in for the OpenRouter transport: it keeps the
+// request the summary processor built and answers with a scripted summary.
+type recordingClient struct {
+ params []orclient.RequestParams
+ summary string
+}
+
+func (client *recordingClient) Stream(
+ _ context.Context, params orclient.RequestParams,
+) (steploop.PartStream, error) {
+ client.params = append(client.params, params)
+ total := float64(31_000)
+ out := float64(120)
+ return &steploop.SliceStream{Parts: []orclient.StreamPart{
+ orclient.TextStartPart{ID: "t1"},
+ orclient.TextDeltaPart{ID: "t1", Delta: client.summary},
+ orclient.TextEndPart{ID: "t1"},
+ orclient.FinishPart{
+ FinishReason: orclient.FinishReason{Unified: "stop"},
+ Usage: calc.LanguageModelV3Usage{
+ InputTokens: calc.LanguageModelV3InputTokens{Total: &total},
+ OutputTokens: calc.LanguageModelV3OutputTokens{Total: &out},
+ },
+ },
+ }}, nil
+}
+
+func wireTestModel() compaction.Model {
+ return compaction.Model{
+ Message: msgmodel.Model{
+ ProviderID: "openrouter", ID: "vendor/model",
+ API: msgmodel.ModelAPI{Npm: "@openrouter/ai-sdk-provider", ID: "vendor/model"},
+ },
+ Overflow: overflow.Model{Limit: calc.ModelLimit{Context: 131_072, Output: 8_192}},
+ }
+}
+
+type wireTestProvider struct{ model compaction.Model }
+
+func (p wireTestProvider) GetModel(context.Context, string, string) (compaction.Model, error) {
+ return p.model, nil
+}
+
+func (wireTestProvider) GetProvider(context.Context, string) (compaction.ProviderInfo, error) {
+ return compaction.ProviderInfo{}, nil
+}
+
+func validWireSummary() string {
+ return strings.Join([]string{
+ "## Working State",
+ "### Completed", "- parse() implemented in src/a.js",
+ "### Current", "- (none)",
+ "### Verification", "- npm test: 2 failing",
+ "### Next", "- fix the failing assertions",
+ "### Files", "- src/a.js",
+ }, "\n")
+}
+
+// Pinned end to end through the real summary factory, the real step
+// processor, and the real request-body builder: the bytes that would leave
+// for OpenRouter must carry the flattened transcript.
+func TestSummaryRequestReachesTheWireWithTheTranscript(t *testing.T) {
+ store := newTurnStore()
+ client := &recordingClient{summary: validWireSummary()}
+ var decisions []compaction.CompactionDecision
+ budget := float64(0)
+ service := compaction.NewService(compaction.Dependencies{
+ Store: store,
+ Config: compaction.ConfigProviderFunc(func(context.Context) (overflow.Config, error) {
+ return overflow.Config{Compaction: &overflow.CompactionConfig{
+ PreserveRecentTokens: &budget,
+ }}, nil
+ }),
+ Agents: compaction.AgentProviderFunc(func(context.Context, string) (compaction.Agent, error) {
+ return compaction.Agent{Name: "compaction"}, nil
+ }),
+ Provider: wireTestProvider{model: wireTestModel()},
+ Processors: seniorDevSummaryFactory{store: store, client: client},
+ Evidence: compaction.FallbackEvidenceSelector{},
+ Decisions: compaction.DecisionSinkFunc(func(d compaction.CompactionDecision) {
+ decisions = append(decisions, d)
+ }),
+ Instance: compaction.InstanceContext{Directory: t.TempDir()},
+ NewID: steploop.NewAscendingID,
+ })
+
+ const goal = "Fix parse() in src/a.js so nested refs resolve."
+ ctx := context.Background()
+ user := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: "u0", SessionID: "ses"},
+ Agent: "coder",
+ Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "vendor/model"},
+ }
+ finish := "tool_calls"
+ messages := []msgmodel.WithParts{
+ {Info: user, Parts: msgmodel.Parts{msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "p0", SessionID: "ses", MessageID: "u0"}, Text: goal,
+ }}},
+ {Info: msgmodel.Assistant{
+ MessageBase: msgmodel.MessageBase{ID: "a0", SessionID: "ses"}, ParentID: "u0",
+ ModelID: "vendor/model", ProviderID: "openrouter", Finish: &finish,
+ }, Parts: msgmodel.Parts{msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "p1", SessionID: "ses", MessageID: "a0"},
+ Text: "Reading src/a.js before editing.",
+ }}},
+ {Info: msgmodel.Assistant{
+ MessageBase: msgmodel.MessageBase{ID: "a1", SessionID: "ses"}, ParentID: "u0",
+ ModelID: "vendor/model", ProviderID: "openrouter", Finish: &finish,
+ }, Parts: msgmodel.Parts{msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{ID: "p2", SessionID: "ses", MessageID: "a1"},
+ Text: "Newest message, kept verbatim.",
+ }}},
+ {Info: msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: "uc", SessionID: "ses"}, Agent: "coder",
+ Model: user.Model,
+ }, Parts: msgmodel.Parts{msgmodel.CompactionPart{
+ PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses", MessageID: "uc"}, Auto: true,
+ }}},
+ }
+ for _, message := range messages {
+ if err := store.UpdateMessage(ctx, message.Info); err != nil {
+ t.Fatal(err)
+ }
+ for _, part := range message.Parts {
+ if err := store.UpdatePart(ctx, part); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ result, err := service.Process(ctx, compaction.ProcessInput{
+ ParentID: "uc", Messages: messages, SessionID: "ses", Auto: true,
+ })
+ if err != nil || result != steploop.ResultContinue {
+ t.Fatalf("result=%s err=%v", result, err)
+ }
+ if len(client.params) != 1 {
+ t.Fatalf("summary calls = %d, want 1", len(client.params))
+ }
+ params := client.params[0]
+ if params.Tools != nil || params.ToolChoice != nil {
+ t.Fatalf("summary request carried tools: %#v", params.Tools)
+ }
+ body, err := orclient.BuildRequestBody(params)
+ if err != nil {
+ t.Fatalf("the summary request does not build a request body: %v", err)
+ }
+ var decoded struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ t.Fatalf("body shape: %v\n%s", err, body)
+ }
+ if len(decoded.Messages) != 1 || decoded.Messages[0].Role != "user" {
+ t.Fatalf("wire messages = %#v", decoded.Messages)
+ }
+ content := decoded.Messages[0].Content
+ for _, want := range []string{
+ "", "[User]: " + goal, "[Assistant]: Reading src/a.js before editing.",
+ "", compaction.SummaryTemplate,
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("wire content missing %q:\n%s", want, content)
+ }
+ }
+ if strings.Contains(content, "Newest message, kept verbatim.") {
+ t.Fatalf("the verbatim tail was sent to the summarizer:\n%s", content)
+ }
+ if len(decisions) != 1 || decisions[0].SummaryStatus != "valid" ||
+ decisions[0].SummaryPromptTokens != 31_000 || decisions[0].SummaryOutputTokens != 120 ||
+ decisions[0].PromptChars < len(content) {
+ t.Fatalf("decision = %#v", decisions[0])
+ }
+}
+
+func gitIn(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ command := exec.Command("git", append([]string{
+ "-c", "user.name=t", "-c", "user.email=t@example.com",
+ }, args...)...)
+ command.Dir = dir
+ out, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+func TestChangedFilesRecordDiffsAgainstTheStartRefAndListsStatus(t *testing.T) {
+ dir := t.TempDir()
+ gitIn(t, dir, "init", "-q")
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ gitIn(t, dir, "add", "a.txt")
+ gitIn(t, dir, "commit", "-q", "-m", "base")
+ gitIn(t, dir, "update-ref", soloStartRef, gitIn(t, dir, "rev-parse", "HEAD"))
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "probe.txt"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ record := strings.Join(seniorDevChangedFiles(context.Background(), dir), "\n")
+ for _, want := range []string{
+ "git diff --stat " + soloStartRef, "a.txt | 1 +", "git status --short", "?? probe.txt",
+ } {
+ if !strings.Contains(record, want) {
+ t.Fatalf("record missing %q:\n%s", want, record)
+ }
+ }
+ if got := seniorDevChangedFiles(context.Background(), t.TempDir()); got != nil {
+ t.Fatalf("non-repository produced a record: %v", got)
+ }
+}
diff --git a/internal/seniordev/app/engine_contract_test.go b/internal/seniordev/app/engine_contract_test.go
new file mode 100644
index 0000000000..0144d39205
--- /dev/null
+++ b/internal/seniordev/app/engine_contract_test.go
@@ -0,0 +1,268 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard"
+)
+
+func recordedResponse(
+ request *http.Request, status int, contentType string, body string,
+) *http.Response {
+ recorder := httptest.NewRecorder()
+ recorder.Header().Set("Content-Type", contentType)
+ recorder.WriteHeader(status)
+ _, _ = recorder.WriteString(body)
+ response := recorder.Result()
+ response.Request = request
+ return response
+}
+
+func TestSeniorDevEngineStreamsShapesAndRepairsMisCasedToolCall(t *testing.T) {
+ // senior-dev uses the OpenRouter streaming/request-shaping path, and a
+ // mis-cased tool name (BASH for bash) is repaired before execute.
+ var requests [][]byte
+ var executed steploop.ToolCall
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ raw, err := io.ReadAll(request.Body)
+ if err != nil {
+ return nil, err
+ }
+ requests = append(requests, raw)
+ if len(requests) == 1 {
+ return recordedResponse(
+ request, http.StatusOK, "text/event-stream",
+ toolCallReply("BASH", `{"command":"true"}`),
+ ), nil
+ }
+ return recordedResponse(
+ request, http.StatusOK, "text/event-stream", chatReply("done", 10),
+ ), nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client, variant: "high"}
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen3.6-plus",
+ Prompt: "repair the tool", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt,
+ Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{
+ Type: "function", Name: "bash", Description: "run a command",
+ InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`),
+ }}},
+ Execute: func(_ context.Context, call steploop.ToolCall) (steploop.ToolResult, error) {
+ executed = call
+ return steploop.ToolResult{Output: "ok"}, nil
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if executed.Name != "bash" || result.Text != "done" || len(result.Parts) != 2 ||
+ result.Parts[0].Tool != "bash" || result.Parts[0].Status != "completed" {
+ t.Fatalf("executed=%+v result=%+v", executed, result)
+ }
+ if result.CostUSD != 0.02 ||
+ result.Parts[0].CostUSD == nil || *result.Parts[0].CostUSD != 0.01 {
+ t.Fatalf("cost ledger = total %v part %+v", result.CostUSD, result.Parts[0])
+ }
+ var body map[string]any
+ if err := json.Unmarshal(requests[0], &body); err != nil {
+ t.Fatal(err)
+ }
+ usage, _ := body["usage"].(map[string]any)
+ reasoning, _ := body["reasoning"].(map[string]any)
+ if body["stream"] != true || usage["include"] != true ||
+ body["max_tokens"] != float64(32_000) || reasoning["effort"] != "high" ||
+ body["prompt_cache_key"] == "" {
+ t.Fatalf("shaped request = %s", requests[0])
+ }
+ // No source set a sampling parameter, so none is sent: the provider's own
+ // default applies.
+ for _, key := range []string{"temperature", "top_p", "top_k", "seed", "provider"} {
+ if _, present := body[key]; present {
+ t.Fatalf("unconfigured %s reached the wire: %s", key, requests[0])
+ }
+ }
+}
+
+func TestSeniorDevAdaptiveRouterFailsOverAndRegistersOutcomes(t *testing.T) {
+ // A failed request is registered but never replayed inside the engine. The
+ // next caller-owned turn resolves through the same live router and selects
+ // another pool candidate.
+ nowMS := float64(1_700_000_000_000)
+ restoreNow := orclient.SetNowForTesting(func() float64 {
+ nowMS += 1_000
+ return nowMS
+ })
+ defer restoreNow()
+ seed := float64(4)
+ var eventMu sync.Mutex
+ events := []adaptive.AdaptiveRouteEvent{}
+ router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{
+ HighModels: []adaptive.ModelCandidate{
+ testRouterCandidate("openrouter/qwen/qwen-primary", 0),
+ testRouterCandidate("openrouter/deepseek/deepseek-secondary", 1),
+ },
+ RandomSeed: &seed,
+ OnEvent: func(event adaptive.AdaptiveRouteEvent) {
+ eventMu.Lock()
+ events = append(events, event)
+ eventMu.Unlock()
+ },
+ })
+ models := []string{}
+ primary := ""
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ var body struct {
+ Model string `json:"model"`
+ }
+ if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
+ return nil, err
+ }
+ models = append(models, body.Model)
+ if primary == "" {
+ primary = body.Model
+ }
+ if body.Model == primary {
+ return recordedResponse(
+ request, http.StatusInternalServerError, "application/json",
+ `{"error":{"message":"Provider returned error"}}`,
+ ), nil
+ }
+ return recordedResponse(
+ request, http.StatusOK, "text/event-stream", chatReply("recovered", 10),
+ ), nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client, router: router}
+ request := turn{
+ Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen-primary",
+ Prompt: "fail over", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt,
+ }
+ if _, err := backend.Run(context.Background(), request); err == nil {
+ t.Fatal("primary provider failure returned nil")
+ }
+ if len(models) != 1 {
+ t.Fatalf("first turn made %d model requests, want exactly 1", len(models))
+ }
+ result, err := backend.Run(context.Background(), request)
+ if err != nil || result.Text != "recovered" {
+ t.Fatalf("caller-owned recovery turn = (%+v, %v)", result, err)
+ }
+ if len(models) != 2 || models[0] == models[1] {
+ t.Fatalf("routed models = %v, want failover", models)
+ }
+ eventMu.Lock()
+ defer eventMu.Unlock()
+ if len(events) != 2 || events[0].Failures != 1 || events[0].Error == "" ||
+ events[0].ElapsedS != 1 || events[1].Successes != 1 ||
+ events[1].ElapsedS != 1 || events[1].Error != "" {
+ t.Fatalf("router outcomes = %#v", events)
+ }
+}
+
+func TestSeniorDevCostCapTripsFromEngineLedger(t *testing.T) {
+ // Provider usage reaches the turn result and is charged to its first tool
+ // action, so a cost cap can be enforced from the engine's own ledger.
+ responses := []string{
+ toolCallReply("bash", `{"command":"true"}`),
+ chatReply("done", 10),
+ }
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ response := responses[0]
+ responses = responses[1:]
+ return recordedResponse(request, http.StatusOK, "text/event-stream", response), nil
+ })}
+ runtime := newRuntime(t.TempDir(), &modelAPIBackend{api: testModelAPI, client: client})
+ t.Cleanup(runtime.Close)
+ result, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ProviderID: "openrouter",
+ ModelID: "qwen/qwen3.6-plus", Workspace: t.TempDir(), Prompt: "spend once",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.CostUSD != 0.02 {
+ t.Fatalf("turn ledger = total %v", result.CostUSD)
+ }
+ maxCost := 0.005
+ guard := loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{MaxCostUsd: &maxCost})
+ var verdict loopguard.LoopVerdict
+ for _, part := range result.Parts {
+ if part.Type == "tool" {
+ verdict = guard.Observe(loopguard.LoopAction{
+ Tool: part.Tool, ArgsKey: part.ArgsKey, CostUsd: part.CostUSD,
+ })
+ }
+ }
+ if verdict.Status != loopguard.LoopStatusStop || verdict.Reason == nil ||
+ !strings.Contains(*verdict.Reason, "cost budget reached") {
+ t.Fatalf("cost verdict = %#v; parts=%#v", verdict, result.Parts)
+ }
+}
+
+func TestSeniorDevDeadlineCancelsMidStream(t *testing.T) {
+ // The caller deadline reaches an already-open SSE stream
+ // and terminates it without waiting for provider/watchdog timeouts.
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ body := &deadlineStreamBody{
+ ctx: request.Context(),
+ first: bytes.NewReader([]byte(
+ "data: {\"id\":\"gen-deadline\",\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
+ )),
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: body, Request: request,
+ }, nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client}
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ started := time.Now()
+ _, err := backend.Run(ctx, turn{
+ Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen3.6-plus",
+ Prompt: "wait", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt,
+ })
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("deadline error = %v", err)
+ }
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("mid-stream cancellation took %s", elapsed)
+ }
+}
+
+func testRouterCandidate(id string, priority float64) adaptive.ModelCandidate {
+ return adaptive.ModelCandidate{
+ ID: id, PromptUSDPerMtok: 1, CompletionUSDPerMtok: 1,
+ Priority: float64(priority),
+ }
+}
+
+type deadlineStreamBody struct {
+ ctx context.Context
+ first *bytes.Reader
+}
+
+func (body *deadlineStreamBody) Read(target []byte) (int, error) {
+ if body.first.Len() > 0 {
+ return body.first.Read(target)
+ }
+ <-body.ctx.Done()
+ return 0, body.ctx.Err()
+}
+
+func (*deadlineStreamBody) Close() error { return nil }
diff --git a/internal/seniordev/app/engine_prompt_test.go b/internal/seniordev/app/engine_prompt_test.go
new file mode 100644
index 0000000000..c5ed217993
--- /dev/null
+++ b/internal/seniordev/app/engine_prompt_test.go
@@ -0,0 +1,233 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config"
+ "github.com/Agent-Field/codeaf/internal/seniordev/project"
+)
+
+func systemTextFromRequest(t *testing.T, raw []byte) string {
+ t.Helper()
+ var body struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.Messages) == 0 || body.Messages[0].Role != "system" {
+ t.Fatalf("request has no leading system message: %s", raw)
+ }
+ var content []struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(body.Messages[0].Content, &content); err != nil || len(content) != 1 {
+ t.Fatalf("invalid system content: %s", body.Messages[0].Content)
+ }
+ return content[0].Text
+}
+
+func TestCoderRequestSystemPromptOrderAndEnvironment(t *testing.T) {
+ // A coder request strips frontmatter, keeps the system prompt in order
+ // (role, model line, root instructions), and carries every environment
+ // field.
+ t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0")
+ workspace := t.TempDir()
+ active := filepath.Join(workspace, "nested")
+ if err := gitRun(workspace, "init", "-b", "main"); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(active, "placeholder"), "x\n"); err != nil {
+ t.Fatal(err)
+ }
+ rawAgent, ok := baked.GetBakedAgentMarkdown("coder")
+ if !ok {
+ t.Fatal("missing coder")
+ }
+
+ var requestBody []byte
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ var err error
+ requestBody, err = readRequestBody(request)
+ if err != nil {
+ return nil, err
+ }
+ return recordedResponse(request, http.StatusOK, "text/event-stream", chatReply("done", 10)), nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client}
+ vcs := "git"
+ ctx := project.WithContext(context.Background(), project.InstanceContext{
+ Directory: active, Worktree: workspace,
+ Project: project.Info{Worktree: workspace, VCS: &vcs},
+ })
+ _, err := backend.Run(ctx, turn{
+ Agent: "coder", AgentMarkdown: rawAgent,
+ ProviderID: "openrouter", ModelID: "openai/gpt-6.1-codex",
+ Workspace: active, Prompt: "implement it",
+ SystemInstructions: []string{"ROOT INSTRUCTION"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ system := systemTextFromRequest(t, requestBody)
+ for _, forbidden := range []string{"---\nmode: subagent", "permission:\n", "model: inherit"} {
+ if strings.Contains(system, forbidden) {
+ t.Fatalf("frontmatter fragment %q reached request:\n%s", forbidden, system)
+ }
+ }
+ ordered := []string{
+ "",
+ "You are powered by the model named openai/gpt-6.1-codex.",
+ "ROOT INSTRUCTION",
+ }
+ position := -1
+ for _, fragment := range ordered {
+ next := strings.Index(system, fragment)
+ if next <= position {
+ t.Fatalf("system sequence missing or reordered at %q:\n%s", fragment, system)
+ }
+ position = next
+ }
+ for _, field := range []string{
+ "The exact model ID is openrouter/openai/gpt-6.1-codex",
+ " Working directory: " + active,
+ " Workspace root folder: " + workspace,
+ " Is directory a git repo: yes",
+ " Platform: " + runtime.GOOS,
+ " Today's date: ",
+ } {
+ if !strings.Contains(system, field) {
+ t.Errorf("environment missing %q:\n%s", field, system)
+ }
+ }
+}
+
+func TestComposeTurnSystemRequiresAnAgentPrompt(t *testing.T) {
+ // There is no model-family base prompt behind the agent prompt: a turn
+ // without one is refused instead of being sent with an empty role.
+ t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0")
+ if _, err := composeTurnSystem(
+ context.Background(), turn{Agent: "coder", Workspace: t.TempDir()},
+ "openrouter", "deepseek/deepseek-v3", nil,
+ ); err == nil || !strings.Contains(err.Error(), "no system prompt") {
+ t.Fatalf("empty agent prompt composed a system prompt: err=%v", err)
+ }
+ agent := "specialist"
+ system, err := composeTurnSystem(
+ context.Background(), turn{Workspace: t.TempDir(), AgentMarkdown: agent},
+ "openrouter", "deepseek/deepseek-v3", nil,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(system, agent+"\nYou are powered") {
+ t.Fatalf("agent prompt must open the system prompt directly:\n%s", system)
+ }
+}
+
+func TestConfiguredAgentPromptIsPassedVerbatim(t *testing.T) {
+ // A configured `agent.prompt` reaches the model verbatim. Only baked agent
+ // documents carry YAML frontmatter, so a config string that merely opens
+ // with a Markdown rule must survive whole.
+ t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0")
+ config := &seniorDevConfig{info: configpkg.Info{
+ "agent": map[string]any{
+ "coder": map[string]any{"prompt": "---\nHouse rules\n---\nAlways run the linter."},
+ },
+ }}
+ configured, err := config.configureTurn(turn{Agent: "coder", Workspace: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ system, err := composeTurnSystem(
+ context.Background(), configured, "openrouter", "deepseek/deepseek-v3", nil,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, fragment := range []string{"House rules", "Always run the linter."} {
+ if !strings.Contains(system, fragment) {
+ t.Fatalf("configured prompt lost %q:\n%s", fragment, system)
+ }
+ }
+
+ // An unterminated leading rule must not empty the prompt.
+ config.info = configpkg.Info{"agent": map[string]any{
+ "coder": map[string]any{"prompt": "---\nOnly one rule: be careful."},
+ }}
+ configured, err = config.configureTurn(turn{Agent: "coder", Workspace: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ system, err = composeTurnSystem(
+ context.Background(), configured, "openrouter", "deepseek/deepseek-v3", nil,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(system, "Only one rule: be careful.") {
+ t.Fatalf("unterminated rule emptied the configured prompt:\n%s", system)
+ }
+}
+
+func TestBakedRuntimeControlsKeepExplicitPoolModel(t *testing.T) {
+ // coder.md declares `model: inherit` and no step cap: the pool model the
+ // caller chose stays, and no baked cap is invented.
+ cfg := &seniorDevConfig{}
+ configured, err := cfg.configureTurn(turn{
+ Agent: "coder", ProviderID: "openrouter",
+ ModelID: "deepseek/deepseek-v4-flash-0731",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if configured.ProviderID != "openrouter" ||
+ configured.ModelID != "deepseek/deepseek-v4-flash-0731" {
+ t.Fatalf("baked model overrode the explicit pool model: %+v", configured)
+ }
+ if configured.MaxSteps != nil {
+ t.Fatalf("coder max steps = %v, want none from the baked document", *configured.MaxSteps)
+ }
+}
+
+func TestConfiguredTurnEmitsEffectiveRuntimeProvenance(t *testing.T) {
+ var output bytes.Buffer
+ runtime := &runtimeAdapter{
+ config: &seniorDevConfig{}, events: newEventWriter(&output),
+ }
+ if _, err := runtime.configureTurn(turn{
+ Agent: "coder", SessionID: "ses-coder",
+ AgentMarkdown: "coder prompt", ProviderID: "openrouter",
+ ModelID: "deepseek/model",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, fragment := range []string{
+ `"stage":"agent-runtime"`, `"agent":"coder"`,
+ `"session_id":"ses-coder"`, `"model_id":"deepseek/model"`,
+ `"prompt_sha256"`,
+ } {
+ if !strings.Contains(output.String(), fragment) {
+ t.Fatalf("runtime provenance missing %s: %s", fragment, output.String())
+ }
+ }
+}
+
+func readRequestBody(request *http.Request) ([]byte, error) {
+ defer request.Body.Close()
+ return io.ReadAll(request.Body)
+}
diff --git a/internal/seniordev/app/engine_router.go b/internal/seniordev/app/engine_router.go
new file mode 100644
index 0000000000..9d7367326b
--- /dev/null
+++ b/internal/seniordev/app/engine_router.go
@@ -0,0 +1,54 @@
+//go:build !windows
+
+package app
+
+import (
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/state"
+)
+
+type adaptiveRouterBackend interface {
+ setAdaptiveRouter(*adaptive.AdaptiveModelRouter)
+}
+
+func (backend *modelAPIBackend) setAdaptiveRouter(router *adaptive.AdaptiveModelRouter) {
+ backend.router = router
+}
+
+func initRunRouter(args cliArgs, events ...*eventWriter) *adaptive.AdaptiveModelRouter {
+ handle := state.InitRouter(adaptive.AdaptiveRouterConfig{
+ // An empty low or frontier pool is left empty: the router routes
+ // that tier on the high pool.
+ HighModels: configuredCandidates(args.High, adaptive.ModelTierHigh),
+ LowModels: configuredCandidates(args.Low, adaptive.ModelTierLow),
+ FrontierModels: configuredCandidates(args.Frontier, adaptive.ModelTierFrontier),
+ OnEvent: func(event adaptive.AdaptiveRouteEvent) {
+ state.EmitRouteEvent(state.ToRouteEvent(event))
+ if len(events) > 0 && events[0] != nil &&
+ (event.Reason == "caller-canceled-pick" || event.Reason == "caller-canceled-request") {
+ events[0].stage("router-cancellation", event.Reason, map[string]any{
+ "slot": event.Slot, "tier": event.Tier, "model": event.Model,
+ "provider_health_changed": false,
+ })
+ }
+ // THE CODER MOVED TO ANOTHER MODEL: a stage, so codeaf's page can
+ // say why the model answering the work changed. Only a real change
+ // is one — a pick that stayed, or the first pick of the run, moved
+ // nothing — and only the coder's, because the history summary's
+ // model is not the one doing the work. The same event is on stderr
+ // as a `[router]` line, whole; the stage reports it and decides
+ // nothing.
+ if len(events) > 0 && events[0] != nil && event.Switched && event.Slot == "coder" {
+ events[0].stage("model-switch", "switched", map[string]any{
+ "from": event.PreviousModel, "to": event.Model, "reason": event.Reason,
+ })
+ }
+ },
+ })
+ router, _ := state.AdaptiveRouter(handle)
+ return router
+}
+
+func configuredCandidates(raw string, tier adaptive.ModelTier) []adaptive.ModelCandidate {
+ return adaptive.ParseModelList(&raw, tier)
+}
diff --git a/internal/seniordev/app/engine_store.go b/internal/seniordev/app/engine_store.go
new file mode 100644
index 0000000000..0987981a57
--- /dev/null
+++ b/internal/seniordev/app/engine_store.go
@@ -0,0 +1,103 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+)
+
+// turnStore is the in-memory fallback for direct engine tests. The shipped
+// senior-dev runtime supplies its run-scoped durable session store instead.
+type turnStore struct {
+ mu sync.Mutex
+ order []string
+ infos map[string]msgmodel.Info
+ parts map[string][]msgmodel.Part
+}
+
+func newTurnStore() *turnStore {
+ return &turnStore{
+ infos: map[string]msgmodel.Info{},
+ parts: map[string][]msgmodel.Part{},
+ }
+}
+
+func (store *turnStore) Messages(
+ _ context.Context, sessionID string,
+) ([]msgmodel.WithParts, error) {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ out := make([]msgmodel.WithParts, 0, len(store.order))
+ for _, id := range store.order {
+ info := store.infos[id]
+ if info == nil {
+ continue
+ }
+ withParts := msgmodel.WithParts{Info: info, Parts: store.parts[id]}
+ if sessionID != "" {
+ switch typed := info.(type) {
+ case msgmodel.User:
+ if typed.SessionID != sessionID {
+ continue
+ }
+ case msgmodel.Assistant:
+ if typed.SessionID != sessionID {
+ continue
+ }
+ }
+ }
+ copied, err := copyTurnMessage(withParts)
+ if err != nil {
+ return nil, fmt.Errorf("senior-dev turn store: copy %s: %w", id, err)
+ }
+ out = append(out, copied)
+ }
+ return out, nil
+}
+
+func (store *turnStore) UpdateMessage(_ context.Context, info msgmodel.Info) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ id := info.MessageID()
+ if _, exists := store.infos[id]; !exists {
+ store.order = append(store.order, id)
+ }
+ store.infos[id] = info
+ return nil
+}
+
+func (store *turnStore) UpdatePart(_ context.Context, part msgmodel.Part) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ base := part.Base()
+ parts := store.parts[base.MessageID]
+ for index := range parts {
+ if parts[index].Base().ID == base.ID {
+ parts[index] = part
+ store.parts[base.MessageID] = parts
+ return nil
+ }
+ }
+ store.parts[base.MessageID] = append(parts, part)
+ return nil
+}
+
+func copyTurnMessage(input msgmodel.WithParts) (msgmodel.WithParts, error) {
+ if input.Parts == nil {
+ input.Parts = msgmodel.Parts{}
+ }
+ raw, err := json.Marshal(input)
+ if err != nil {
+ return msgmodel.WithParts{}, err
+ }
+ var output msgmodel.WithParts
+ if err := json.Unmarshal(raw, &output); err != nil {
+ return msgmodel.WithParts{}, err
+ }
+ return output, nil
+}
diff --git a/internal/seniordev/app/events.go b/internal/seniordev/app/events.go
new file mode 100644
index 0000000000..9d00f849ea
--- /dev/null
+++ b/internal/seniordev/app/events.go
@@ -0,0 +1,232 @@
+//go:build !windows
+
+// This file is where the run's records go: the stage and step records the
+// run reports to codeaf, and the run's own log of every record and bus
+// payload for the tests that read one.
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+)
+
+type event struct {
+ Type string `json:"type"`
+ Stage string `json:"stage,omitempty"`
+ Status string `json:"status,omitempty"`
+ Message string `json:"message,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ Data map[string]any `json:"data,omitempty"`
+ Timestamp int64 `json:"ts"`
+ // `spend` only. A pointer because a run that has cost nothing yet still
+ // reports a figure, and omitempty would drop a real zero.
+ CostUSD *float64 `json:"cost_usd,omitempty"`
+ // `step` only: what was run, and what came back; the tool that ran it, the
+ // step of senior-dev's process it served (step_ids.go), and a command's
+ // exit code when it has one.
+ Command string `json:"command,omitempty"`
+ Observation string `json:"observation,omitempty"`
+ Tool string `json:"tool,omitempty"`
+ Step string `json:"step,omitempty"`
+ Exit *int `json:"exit,omitempty"`
+ // Added and Removed are a file tool's lines added and removed.
+ Added *int `json:"added,omitempty"`
+ Removed *int `json:"removed,omitempty"`
+}
+
+// recordSink is where the run's protocol records go: codeaf, through the
+// delegate.Host the run command was handed. It takes the two records the run
+// writes as it goes; the first (hello) and the last (terminal) are the run
+// command's own, because it is the one place that sees every ending.
+type recordSink interface {
+ Stage(stage delegate.StageRecord)
+ Step(step delegate.StepRecord)
+}
+
+// eventWriter is the run's one outlet for what it has to say.
+//
+// STDOUT CARRIES THE PROTOCOL'S RECORDS AND NOTHING ELSE (docs/design/delegate/
+// PROTOCOL.md), and it is codeaf's: a run codeaf hosts reports its stages and
+// its finished steps through the host, and those are the only two records it
+// writes as it goes. The instance bus's payloads — sessions, messages, parts,
+// questions, model requests — and the `spend` record stay inside the process.
+// The bus still carries them, and this writer still reads them: a finished
+// tool part is a step, and the assistant messages are what the agent summary
+// is added up from. Money is not reported here at all, because codeaf's model
+// API meters every call itself.
+//
+// A test that wants to read the run the way senior-dev's own stream used to
+// show it hands newEventWriter a writer instead, and gets every record and
+// every bus payload on it, one JSON object per line.
+type eventWriter struct {
+ mu sync.Mutex
+ // records is the host, in a run codeaf started. Nil in the tests that read
+ // the log instead.
+ records recordSink
+ // encoder is the log: every record and bus payload, for a test. Nil in a
+ // run codeaf started, where nothing but the host's records may reach stdout.
+ encoder *json.Encoder
+ // notes is where a stage's data goes for a person: one line per stage, on
+ // stderr, which codeaf keeps in a file beside the task. The protocol's
+ // stage record carries a curated copy of it (stage_data.go).
+ notes io.Writer
+ summary *agentSummary
+ // steps deduplicates `step` records: a tool part is republished as its
+ // state moves, so the same finished call arrives more than once.
+ steps map[string]struct{}
+ // progress is what the step classifier knows of the run so far
+ // (step_ids.go): whether a project file has changed, whether a submit was
+ // accepted. It is read and moved under mu, in the order the calls finish
+ // and the freeze's stage record is written.
+ progress stepProgress
+}
+
+// newEventWriter is a writer whose only outlet is output: every record and
+// every bus payload, one JSON object per line. It is the tests' view of a run.
+func newEventWriter(output io.Writer) *eventWriter {
+ return &eventWriter{
+ encoder: json.NewEncoder(output),
+ summary: newAgentSummary(),
+ steps: map[string]struct{}{},
+ }
+}
+
+// newRecordWriter is the writer of a run codeaf hosts: stages and steps to
+// records, and each stage's data as one line on notes.
+func newRecordWriter(records recordSink, notes io.Writer) *eventWriter {
+ return &eventWriter{
+ records: records,
+ notes: notes,
+ summary: newAgentSummary(),
+ steps: map[string]struct{}{},
+ }
+}
+
+func (writer *eventWriter) emit(value event) {
+ if writer == nil {
+ return
+ }
+ if value.Timestamp == 0 {
+ value.Timestamp = time.Now().UnixMilli()
+ }
+ // ONE LOCK, SO THE RECORDS KEEP THE ORDER THE RUN MADE THEM IN. Two
+ // goroutines of the run can report at once (a tool finishing while the
+ // stage machine moves on), and codeaf reads the order as the order things
+ // happened in.
+ writer.mu.Lock()
+ defer writer.mu.Unlock()
+ if writer.encoder != nil {
+ _ = writer.encoder.Encode(value)
+ }
+ switch value.Type {
+ case "stage":
+ // The freeze's stage record is what says a submit was accepted, so it
+ // moves the step classifier's progress here, under the same lock as the
+ // steps and in the order the run wrote them: it is written inside the
+ // submit call, before that call's own step.
+ writer.progress = writer.progress.afterStage(value.Stage, value.Status)
+ if writer.records != nil {
+ writer.records.Stage(delegate.StageRecord{
+ Stage: value.Stage, Status: value.Status, Data: stageRecordData(value.Data),
+ })
+ }
+ writer.noteStage(value)
+ case "step":
+ if writer.records != nil {
+ writer.records.Step(delegate.StepRecord{
+ Command: value.Command, Observation: value.Observation,
+ Tool: value.Tool, Step: value.Step, Exit: value.Exit,
+ Added: value.Added, Removed: value.Removed,
+ })
+ }
+ }
+}
+
+// noteStage writes a stage and its data as one line for a person reading the
+// run's stderr: what the protocol's record has no field for, which is most of
+// what senior-dev knows about why it did what it did.
+func (writer *eventWriter) noteStage(value event) {
+ if writer.notes == nil {
+ return
+ }
+ line := "[senior-dev] " + value.Stage + " · " + value.Status
+ if len(value.Data) > 0 {
+ if data, err := json.Marshal(value.Data); err == nil {
+ line += " " + string(data)
+ }
+ }
+ _, _ = fmt.Fprintln(writer.notes, line)
+}
+
+// busEvent reads one instance-bus payload for what the run reports from it: a
+// finished tool call is a step, and a completed assistant message moves the
+// agent summary. The payload itself reaches only the log.
+func (writer *eventWriter) busEvent(value bus.Payload) {
+ if writer == nil {
+ return
+ }
+ // Observed OUTSIDE the writer lock: the summary keeps its own mutex, so
+ // aggregation never extends the encode critical section.
+ spend, completed := writer.summary.observeBus(value)
+ step, isStep := toolStepRecord(value)
+ writer.mu.Lock()
+ if writer.encoder != nil {
+ _ = writer.encoder.Encode(value)
+ }
+ if isStep {
+ if _, seen := writer.steps[step.key]; seen {
+ isStep = false
+ } else {
+ writer.steps[step.key] = struct{}{}
+ // THE STEP IS NAMED IN THE ORDER THE CALLS FINISHED, under the
+ // same lock that deduplicates them, so the progress it reads is the
+ // run's as of this call and no other.
+ step.step, writer.progress = stepOf(step.action, writer.progress)
+ }
+ }
+ writer.mu.Unlock()
+ // Both are emitted outside the lock, because emit takes the same one.
+ // Neither reaches the model: they are written after the fact, from state
+ // the bus already published.
+ if isStep {
+ writer.emit(event{
+ Type: "step", Command: step.command, Observation: step.observation,
+ Tool: step.action.tool, Step: step.step, Exit: step.exit,
+ Added: step.added, Removed: step.removed,
+ })
+ }
+ // The running total, after the message that moved it, for the log only:
+ // codeaf's model API meters every call itself, so a run it hosts never
+ // reports money.
+ if completed && writer.encoder != nil {
+ total := spend
+ writer.emit(event{Type: "spend", CostUSD: &total})
+ }
+}
+
+func (writer *eventWriter) stage(stage, status string, data map[string]any) {
+ writer.emit(event{Type: "stage", Stage: stage, Status: status, Data: data})
+}
+
+// verifyStep reports one command senior-dev itself ran on the tree — the
+// project's own build or tests, with no model — as a step of its own: the
+// command, its exit code (nil for one that hung or was cut, which has none),
+// and the tail of what it printed.
+//
+// IT REPORTS WHAT RAN, AND CHANGES NOTHING ABOUT IT. The command, its ceiling
+// and how its result is judged are the verification's own
+// (full_verification_run.go); this is written after the command has exited,
+// from the observation the verification already made.
+func (writer *eventWriter) verifyStep(command, tail string, exit *int) {
+ writer.emit(event{
+ Type: "step", Command: "bash: " + oneLine(command),
+ Observation: clipBytes(tail, stepObservationMax),
+ Tool: "bash", Step: StepVerify, Exit: exit,
+ })
+}
diff --git a/internal/seniordev/app/events_agent_summary.go b/internal/seniordev/app/events_agent_summary.go
new file mode 100644
index 0000000000..16819c06de
--- /dev/null
+++ b/internal/seniordev/app/events_agent_summary.go
@@ -0,0 +1,250 @@
+//go:build !windows
+
+package app
+
+import (
+ "sort"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+)
+
+// agentSummary aggregates per-agent model activity from the bus events the
+// run already emits, so the terminal can report where the wall and the cost
+// went without anyone re-deriving it from the raw event stream afterwards.
+// Per-agent wall is a union of intervals over message.updated, computed the
+// same way every time and read as one `agent-summary` stage event.
+//
+// Purely observational: it taps busEvent's existing write path, holds its own
+// lock (never the writer's), and nothing reads it back into a prompt.
+type agentSummary struct {
+ mu sync.Mutex
+ messages map[string]agentMessage
+}
+
+type agentMessage struct {
+ id string
+ sessionID string
+ agent string
+ summary bool
+ upstream string
+ created uint64
+ completed uint64 // 0 until the turn finishes
+ tokensIn uint64
+ tokensOut uint64
+ reasoning uint64
+ cacheRead uint64
+ cost float64
+}
+
+// Cache-miss attribution. A call whose prompt is the previous prompt plus one
+// step should read nearly all of it from the provider cache; a call that reads
+// well under that has lost the prefix. The first call after a compaction
+// boundary is exempt (the prefix was rebuilt on purpose), as is any call too
+// small for a miss to matter. The summary makes the miss count a field, and
+// `cache_misses_after_upstream_switch` says how many of them coincided with
+// OpenRouter changing the serving endpoint.
+const (
+ cacheMissMinPrompt = 4_096
+ // cacheMissReadRatio: cache read below this fraction of the previous
+ // prompt is a miss.
+ cacheMissReadRatio = 0.6
+)
+
+func newAgentSummary() *agentSummary {
+ return &agentSummary{messages: map[string]agentMessage{}}
+}
+
+// observeBus records assistant-message state. message.updated fires more than
+// once per message (created, then completed with tokens), so the map keeps the
+// LAST state per message ID and the rollup counts each message once.
+//
+// It returns the run's cumulative cost and whether THIS payload is the one
+// that completed a message. Those two drive the `spend` record: a reader
+// holding the run to a dollar limit needs a rising total during the run, and
+// summing message.updated itself would double-count, since the same message
+// arrives more than once.
+func (summary *agentSummary) observeBus(value bus.Payload) (float64, bool) {
+ if summary == nil || value.Type != msgmodel.EventMessageUpdated {
+ return 0, false
+ }
+ var info msgmodel.Info
+ switch properties := value.Properties.(type) {
+ case msgmodel.UpdatedEvent:
+ info = properties.Info
+ case *msgmodel.UpdatedEvent:
+ if properties != nil {
+ info = properties.Info
+ }
+ default:
+ return 0, false
+ }
+ var assistant *msgmodel.Assistant
+ switch message := info.(type) {
+ case msgmodel.Assistant:
+ assistant = &message
+ case *msgmodel.Assistant:
+ assistant = message
+ }
+ if assistant == nil || assistant.ID == "" {
+ return 0, false
+ }
+ record := agentMessage{
+ id: assistant.ID,
+ sessionID: assistant.SessionID,
+ agent: assistant.Agent,
+ summary: assistant.Summary != nil && *assistant.Summary,
+ upstream: assistant.Upstream,
+ created: assistant.Time.Created,
+ tokensIn: assistant.Tokens.Input,
+ tokensOut: assistant.Tokens.Output,
+ reasoning: assistant.Tokens.Reasoning,
+ cacheRead: assistant.Tokens.Cache.Read,
+ cost: float64(assistant.Cost),
+ }
+ if assistant.Time.Completed != nil {
+ record.completed = *assistant.Time.Completed
+ }
+ summary.mu.Lock()
+ previous, seen := summary.messages[assistant.ID]
+ summary.messages[assistant.ID] = record
+ completed := record.completed != 0 && (!seen || previous.completed == 0)
+ total := 0.0
+ for _, message := range summary.messages {
+ total += message.cost
+ }
+ summary.mu.Unlock()
+ return total, completed
+}
+
+// data rolls the per-message records up to one map per agent, with wall time
+// as a union of that agent's [created, completed] intervals — concurrent
+// sessions overlap, so a plain sum would overcount.
+func (summary *agentSummary) data() map[string]any {
+ if summary == nil {
+ return nil
+ }
+ summary.mu.Lock()
+ defer summary.mu.Unlock()
+ type rollup struct {
+ calls int
+ intervals [][2]uint64
+ tokensIn uint64
+ tokensOut uint64
+ reasoning uint64
+ cacheRead uint64
+ cost float64
+ misses int
+ missTokens uint64
+ switchMiss int
+ upstreams map[string]int
+ }
+ byAgent := map[string]*rollup{}
+ agentOf := func(message agentMessage) *rollup {
+ agent := message.agent
+ if agent == "" {
+ agent = "(unattributed)"
+ }
+ roll := byAgent[agent]
+ if roll == nil {
+ roll = &rollup{upstreams: map[string]int{}}
+ byAgent[agent] = roll
+ }
+ return roll
+ }
+ bySession := map[string][]agentMessage{}
+ for _, message := range summary.messages {
+ if message.completed == 0 || message.completed < message.created {
+ continue
+ }
+ roll := agentOf(message)
+ roll.calls++
+ roll.intervals = append(roll.intervals, [2]uint64{message.created, message.completed})
+ roll.tokensIn += message.tokensIn
+ roll.tokensOut += message.tokensOut
+ roll.reasoning += message.reasoning
+ roll.cacheRead += message.cacheRead
+ roll.cost += message.cost
+ if message.upstream != "" {
+ roll.upstreams[message.upstream]++
+ }
+ bySession[message.sessionID] = append(bySession[message.sessionID], message)
+ }
+ // Misses are a property of consecutive calls in one session, so they are
+ // attributed on a per-session walk in call order.
+ for _, calls := range bySession {
+ sort.Slice(calls, func(i, j int) bool {
+ if calls[i].created != calls[j].created {
+ return calls[i].created < calls[j].created
+ }
+ return calls[i].id < calls[j].id
+ })
+ var previous *agentMessage
+ afterBoundary := false
+ for index := range calls {
+ call := calls[index]
+ if call.summary {
+ afterBoundary = true
+ continue
+ }
+ prompt := call.tokensIn + call.cacheRead
+ if previous != nil && !afterBoundary && prompt > cacheMissMinPrompt &&
+ float64(call.cacheRead) < cacheMissReadRatio*float64(previous.tokensIn+previous.cacheRead) {
+ roll := agentOf(call)
+ roll.misses++
+ roll.missTokens += call.tokensIn
+ if call.upstream != "" && previous.upstream != "" && call.upstream != previous.upstream {
+ roll.switchMiss++
+ }
+ }
+ afterBoundary = false
+ previous = &calls[index]
+ }
+ }
+ if len(byAgent) == 0 {
+ return nil
+ }
+ agents := map[string]any{}
+ names := make([]string, 0, len(byAgent))
+ for name := range byAgent {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ roll := byAgent[name]
+ agents[name] = map[string]any{
+ "calls": roll.calls,
+ "wall_union_ms": unionMillis(roll.intervals),
+ "tokens_in": roll.tokensIn,
+ "tokens_out": roll.tokensOut,
+ "tokens_reasoning": roll.reasoning,
+ "cache_read": roll.cacheRead,
+ "cost_usd": roll.cost,
+ "cache_misses": roll.misses,
+ "cache_miss_tokens_in": roll.missTokens,
+ "cache_misses_after_upstream_switch": roll.switchMiss,
+ "upstreams": roll.upstreams,
+ }
+ }
+ return map[string]any{"agents": agents}
+}
+
+func unionMillis(intervals [][2]uint64) uint64 {
+ if len(intervals) == 0 {
+ return 0
+ }
+ sort.Slice(intervals, func(i, j int) bool {
+ return intervals[i][0] < intervals[j][0]
+ })
+ var total, start, end uint64 = 0, intervals[0][0], intervals[0][1]
+ for _, interval := range intervals[1:] {
+ if interval[0] > end {
+ total += end - start
+ start, end = interval[0], interval[1]
+ } else if interval[1] > end {
+ end = interval[1]
+ }
+ }
+ return total + end - start
+}
diff --git a/internal/seniordev/app/events_agent_summary_test.go b/internal/seniordev/app/events_agent_summary_test.go
new file mode 100644
index 0000000000..f85caccf58
--- /dev/null
+++ b/internal/seniordev/app/events_agent_summary_test.go
@@ -0,0 +1,276 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+)
+
+func assistantPayload(id, agent string, created, completed uint64, tokens uint64, cost float64) bus.Payload {
+ message := msgmodel.Assistant{
+ Role: "assistant", Agent: agent,
+ Time: msgmodel.AssistantTime{Created: created},
+ Tokens: msgmodel.Tokens{Input: tokens, Output: tokens / 10},
+ Cost: float64(cost),
+ }
+ message.ID = id
+ if completed > 0 {
+ message.Time.Completed = &completed
+ }
+ return bus.Payload{
+ Type: msgmodel.EventMessageUpdated,
+ Properties: msgmodel.UpdatedEvent{SessionID: "ses", Info: message},
+ }
+}
+
+func summaryAgents(t *testing.T, summary *agentSummary) map[string]any {
+ t.Helper()
+ data := summary.data()
+ if data == nil {
+ t.Fatal("summary is empty")
+ }
+ agents, ok := data["agents"].(map[string]any)
+ if !ok {
+ t.Fatalf("data shape = %#v", data)
+ }
+ return agents
+}
+
+// message.updated fires more than once per message — created first, tokens on
+// completion. The rollup must count each message once, at its final state.
+func TestAgentSummaryDeduplicatesMessageUpdates(t *testing.T) {
+ summary := newAgentSummary()
+ summary.observeBus(assistantPayload("m1", "coder", 1000, 0, 0, 0))
+ summary.observeBus(assistantPayload("m1", "coder", 1000, 5000, 400, 0.02))
+
+ agents := summaryAgents(t, summary)
+ coder, ok := agents["coder"].(map[string]any)
+ if !ok {
+ t.Fatalf("agents = %#v", agents)
+ }
+ if coder["calls"] != 1 || coder["tokens_in"] != uint64(400) ||
+ coder["wall_union_ms"] != uint64(4000) {
+ t.Fatalf("coder rollup = %#v", coder)
+ }
+}
+
+// Concurrent sessions overlap; wall must be a union, never a sum. Two calls
+// on [0,10s] and [5s,15s] are 15s of wall, not 20.
+func TestAgentSummaryWallIsAUnionOfIntervals(t *testing.T) {
+ summary := newAgentSummary()
+ summary.observeBus(assistantPayload("m1", "coder", 0, 10_000, 100, 0.01))
+ summary.observeBus(assistantPayload("m2", "coder", 5_000, 15_000, 100, 0.01))
+
+ coder := summaryAgents(t, summary)["coder"].(map[string]any)
+ if coder["calls"] != 2 || coder["wall_union_ms"] != uint64(15_000) {
+ t.Fatalf("coder rollup = %#v", coder)
+ }
+}
+
+// An in-flight message (no completed time) is an incomplete observation and
+// must not enter the rollup — a hard-killed run's dangling turn would
+// otherwise contribute a zero-length or negative interval.
+func TestAgentSummaryIgnoresIncompleteMessages(t *testing.T) {
+ summary := newAgentSummary()
+ summary.observeBus(assistantPayload("m1", "compaction", 1000, 0, 0, 0))
+ if summary.data() != nil {
+ t.Fatalf("incomplete message entered the summary: %#v", summary.data())
+ }
+}
+
+// The bus publishes the concrete UpdatedEvent; pointer forms and non-message
+// events must be tolerated silently — the tap can never panic the writer.
+func TestAgentSummaryToleratesForeignPayloads(t *testing.T) {
+ summary := newAgentSummary()
+ summary.observeBus(bus.Payload{Type: "session.created", Properties: map[string]any{}})
+ summary.observeBus(bus.Payload{Type: msgmodel.EventMessageUpdated, Properties: "garbage"})
+ event := assistantPayload("m1", "coder", 0, 1_000, 10, 0)
+ pointerEvent := event
+ updated := pointerEvent.Properties.(msgmodel.UpdatedEvent)
+ pointerEvent.Properties = &updated
+ summary.observeBus(pointerEvent)
+
+ coder := summaryAgents(t, summary)["coder"].(map[string]any)
+ if coder["calls"] != 1 {
+ t.Fatalf("pointer payload not counted: %#v", coder)
+ }
+ var nilSummary *agentSummary
+ nilSummary.observeBus(event) // must not panic
+ if nilSummary.data() != nil {
+ t.Fatal("nil summary produced data")
+ }
+}
+
+// Wiring test: the busEvent tap must feed the summary, and the rollup must
+// survive a round-trip through the real stage() encoder. The unit tests above
+// exercise the aggregator directly; this one proves the writer is actually
+// plumbed to it, which nothing short of a live run would otherwise check.
+func TestBusEventTapFeedsTheEmittedSummary(t *testing.T) {
+ output := &bytes.Buffer{}
+ writer := newEventWriter(output)
+
+ writer.busEvent(assistantPayload("m1", "coder", 0, 4_000, 300, 0.05))
+ writer.busEvent(assistantPayload("m2", "compaction", 1_000, 9_000, 120, 0.02))
+
+ data := writer.summary.data()
+ if data == nil {
+ t.Fatal("bus tap did not reach the summary")
+ }
+ writer.stage("agent-summary", "completed", data)
+
+ var seen map[string]any
+ for _, line := range strings.Split(output.String(), "\n") {
+ if !strings.Contains(line, `"stage":"agent-summary"`) {
+ continue
+ }
+ var record struct {
+ Data map[string]any `json:"data"`
+ }
+ if json.Unmarshal([]byte(line), &record) == nil {
+ seen = record.Data
+ }
+ }
+ if seen == nil {
+ t.Fatalf("no agent-summary line encoded; output:\n%s", output.String())
+ }
+ agents, ok := seen["agents"].(map[string]any)
+ if !ok || len(agents) != 2 {
+ t.Fatalf("agents = %#v", seen)
+ }
+ compactionAgent, ok := agents["compaction"].(map[string]any)
+ if !ok {
+ t.Fatalf("compaction agent missing: %#v", agents)
+ }
+ // JSON round-trips numbers as float64.
+ if compactionAgent["wall_union_ms"].(float64) != 8_000 {
+ t.Fatalf("compaction wall = %v", compactionAgent["wall_union_ms"])
+ }
+}
+
+// callPayload is one completed call of a session: how much of its prompt was
+// read from the provider cache, and which endpoint served it. A summary flag
+// marks a compaction boundary.
+func callPayload(id, agent string, created, tokensIn, cacheRead uint64, upstream string, summary bool) bus.Payload {
+ message := msgmodel.Assistant{
+ Role: "assistant", Agent: agent,
+ Time: msgmodel.AssistantTime{Created: created},
+ Tokens: msgmodel.Tokens{Input: tokensIn, Cache: msgmodel.TokenCache{Read: cacheRead}},
+ Upstream: upstream,
+ }
+ message.ID = id
+ message.SessionID = "ses"
+ completed := created + 1000
+ message.Time.Completed = &completed
+ if summary {
+ message.Summary = &summary
+ }
+ return bus.Payload{
+ Type: msgmodel.EventMessageUpdated,
+ Properties: msgmodel.UpdatedEvent{SessionID: "ses", Info: message},
+ }
+}
+
+// A call that reads far less of the previous prompt from cache than the
+// prefix it shares is a miss; the first call after a compaction boundary is
+// not (its prefix was rebuilt on purpose), nor is a call too small to matter.
+// Misses that coincide with an endpoint change are counted separately, and
+// the calls per endpoint are reported so the switch rate is visible.
+func TestAgentSummaryAttributesCacheMisses(t *testing.T) {
+ summary := newAgentSummary()
+ summary.observeBus(callPayload("m1", "coder", 1_000, 10_000, 0, "alpha", false)) // first call: no previous prompt
+ summary.observeBus(callPayload("m2", "coder", 2_000, 2_000, 10_000, "alpha", false)) // hit
+ summary.observeBus(callPayload("m3", "coder", 3_000, 12_000, 0, "beta", false)) // miss, on an endpoint switch
+ summary.observeBus(callPayload("m4", "coder", 4_000, 13_000, 1_000, "beta", false)) // miss, same endpoint
+ summary.observeBus(callPayload("m5", "compaction", 5_000, 20_000, 0, "beta", true)) // boundary
+ summary.observeBus(callPayload("m6", "coder", 6_000, 8_000, 0, "beta", false)) // rebuilt prefix: exempt
+ summary.observeBus(callPayload("m7", "coder", 7_000, 500, 8_000, "beta", false)) // hit
+ summary.observeBus(callPayload("m8", "coder", 8_000, 3_000, 0, "alpha", false)) // under the size floor: exempt
+
+ agents := summaryAgents(t, summary)
+ coder := agents["coder"].(map[string]any)
+ if coder["calls"] != 7 || coder["cache_misses"] != 2 ||
+ coder["cache_miss_tokens_in"] != uint64(25_000) ||
+ coder["cache_misses_after_upstream_switch"] != 1 {
+ t.Fatalf("coder rollup = %#v", coder)
+ }
+ upstreams, ok := coder["upstreams"].(map[string]int)
+ if !ok || upstreams["alpha"] != 3 || upstreams["beta"] != 4 {
+ t.Fatalf("coder upstreams = %#v", coder["upstreams"])
+ }
+ compaction := agents["compaction"].(map[string]any)
+ if compaction["calls"] != 1 || compaction["cache_misses"] != 0 {
+ t.Fatalf("compaction rollup = %#v", compaction)
+ }
+}
+
+// The spend record is what a caller enforcing a dollar ceiling reads while the
+// run is still alive. It fires once per message, on the update that completes
+// it, and carries the run's cumulative cost rather than the message's own --
+// summing message.updated directly would double-count, because the same
+// message arrives more than once.
+func TestObserveBusReportsCumulativeSpendOncePerMessage(t *testing.T) {
+ summary := newAgentSummary()
+
+ if _, completed := summary.observeBus(
+ assistantPayload("m1", "coder", 1000, 0, 0, 0),
+ ); completed {
+ t.Fatal("a created-but-unfinished message reported completion")
+ }
+ total, completed := summary.observeBus(
+ assistantPayload("m1", "coder", 1000, 5000, 400, 0.02),
+ )
+ if !completed {
+ t.Fatal("the update that completed m1 did not report completion")
+ }
+ if total != 0.02 {
+ t.Fatalf("cumulative after m1 = %v, want 0.02", total)
+ }
+
+ // A second message, on the compaction agent, adds to the same total.
+ total, completed = summary.observeBus(
+ assistantPayload("m2", "compaction", 6000, 7000, 100, 0.005),
+ )
+ if !completed || total != 0.025 {
+ t.Fatalf("cumulative after m2 = %v (completed=%v), want 0.025 true", total, completed)
+ }
+
+ // A late re-send of an already-complete message must not fire again, or a
+ // reader would see the same spend twice.
+ if _, completed = summary.observeBus(
+ assistantPayload("m1", "coder", 1000, 5000, 400, 0.02),
+ ); completed {
+ t.Fatal("a repeated completed message reported completion twice")
+ }
+}
+
+// The stream carries the record, not just the accumulator.
+func TestBusEventEmitsSpendRecord(t *testing.T) {
+ var stream bytes.Buffer
+ writer := newEventWriter(&stream)
+ writer.busEvent(assistantPayload("m1", "coder", 1000, 0, 0, 0))
+ writer.busEvent(assistantPayload("m1", "coder", 1000, 5000, 400, 0.02))
+
+ var spends []float64
+ for _, line := range bytes.Split(bytes.TrimSpace(stream.Bytes()), []byte("\n")) {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil || value.Type != "spend" {
+ continue
+ }
+ if value.CostUSD == nil {
+ t.Fatalf("spend record carries no cost_usd: %s", line)
+ }
+ spends = append(spends, *value.CostUSD)
+ }
+ if len(spends) != 1 {
+ t.Fatalf("spend records = %d, want 1 (only the completing update)", len(spends))
+ }
+ if spends[0] != 0.02 {
+ t.Fatalf("spend cost_usd = %v, want 0.02", spends[0])
+ }
+}
diff --git a/internal/seniordev/app/events_contract_test.go b/internal/seniordev/app/events_contract_test.go
new file mode 100644
index 0000000000..ae8f088510
--- /dev/null
+++ b/internal/seniordev/app/events_contract_test.go
@@ -0,0 +1,156 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore"
+)
+
+// The bus payloads senior-dev used to print on stdout are still published
+// and still reach the run's log: a test's view of the run. In a run codeaf
+// hosts there is no log on stdout (TestAHostedRunReportsOnlyStagesAndSteps).
+func TestQuestionToolEventsReachTheLogAsBusPayloads(t *testing.T) {
+ workspace := testRepoWithEntrypoints(t)
+ var output bytes.Buffer
+ runner := newPipeline(cliArgs{High: "provider/high"}, workspace, pipelineDeps{
+ Backend: backendFunc(func(context.Context, turn) (turnResult, error) {
+ return turnResult{}, nil
+ }),
+ Events: newEventWriter(&output),
+ })
+ defer runner.runtime.Close()
+ if runner.runtime.initErr != nil {
+ t.Fatal(runner.runtime.initErr)
+ }
+
+ input := json.RawMessage(`{"questions":[{"question":"Continue?","header":"Choice","options":[{"label":"Yes","description":"Continue now"}]}]}`)
+ _, err := runner.runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ ID: "call-question", Name: "question", Input: input,
+ SessionID: "ses-question", MessageID: "msg-question", Agent: "coder",
+ })
+ if err == nil || err.Error() != "The user dismissed this question" {
+ t.Fatalf("question error = %v, want headless rejection", err)
+ }
+
+ seen := map[string]bool{}
+ for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) {
+ var value map[string]json.RawMessage
+ if err := json.Unmarshal(line, &value); err != nil {
+ t.Fatalf("event line %q: %v", line, err)
+ }
+ if len(value) != 3 || value["id"] == nil || value["type"] == nil || value["properties"] == nil {
+ t.Fatalf("bus line keys = %v, want exactly id/type/properties", value)
+ }
+ var eventType string
+ if err := json.Unmarshal(value["type"], &eventType); err != nil {
+ t.Fatal(err)
+ }
+ seen[eventType] = true
+ }
+ if !seen["question.asked"] || !seen["question.rejected"] {
+ t.Fatalf("stdout events = %v, want question.asked and question.rejected; stream=%s", seen, output.String())
+ }
+}
+
+func TestPipelineStreamsBusEventsToTheLog(t *testing.T) {
+ workspace := testRepoWithEntrypoints(t)
+ var output bytes.Buffer
+ runner := newPipeline(cliArgs{High: "provider/high"}, workspace, pipelineDeps{
+ Backend: backendFunc(func(context.Context, turn) (turnResult, error) {
+ return turnResult{}, nil
+ }),
+ Events: newEventWriter(&output),
+ })
+ defer runner.runtime.Close()
+ if runner.runtime.initErr != nil {
+ t.Fatal(runner.runtime.initErr)
+ }
+ if _, err := runner.runtime.durable.sessions.Create(context.Background(), sessioncore.CreateInput{
+ ID: "ses_contract", Title: "contract",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n"))
+ if len(lines) != 2 {
+ t.Fatalf("session creation lines = %d, want session.created then session.updated: %s", len(lines), output.String())
+ }
+ for index, wantType := range []string{"session.created", "session.updated"} {
+ var value map[string]json.RawMessage
+ if err := json.Unmarshal(lines[index], &value); err != nil {
+ t.Fatal(err)
+ }
+ if len(value) != 3 || value["id"] == nil || value["type"] == nil || value["properties"] == nil {
+ t.Fatalf("bus line keys = %v, want exactly id/type/properties", value)
+ }
+ var gotType string
+ if err := json.Unmarshal(value["type"], &gotType); err != nil || gotType != wantType {
+ t.Fatalf("bus line %d type = %q (%v), want %q", index, gotType, err, wantType)
+ }
+ }
+}
+
+// recordedHost is the part of a delegate host the event writer reports to.
+type recordedHost struct {
+ stages []string
+ steps []string
+ stageRecords []delegate.StageRecord
+ stepRecords []delegate.StepRecord
+}
+
+func (host *recordedHost) Stage(stage delegate.StageRecord) {
+ host.stages = append(host.stages, stage.Stage+"/"+stage.Status)
+ host.stageRecords = append(host.stageRecords, stage)
+}
+
+func (host *recordedHost) Step(step delegate.StepRecord) {
+ host.steps = append(host.steps, step.Command)
+ host.stepRecords = append(host.stepRecords, step)
+}
+
+// STDOUT IS THE PROTOCOL'S. A run codeaf hosts reports its stages and its
+// finished steps and nothing else: no bus payload, no spend record, no second
+// copy of a step a republished part would have made. A stage's data goes whole
+// to the notes, which are stderr, for a person, and a curated copy of it rides
+// the stage record; a step says its tool, the step of senior-dev's process it
+// served, and a command's exit code.
+func TestAHostedRunReportsOnlyStagesAndSteps(t *testing.T) {
+ host := &recordedHost{}
+ var notes bytes.Buffer
+ writer := newRecordWriter(host, ¬es)
+
+ writer.stage("implement", "running", map[string]any{"attempt": 0})
+ writer.busEvent(toolPartPayload("c1", "bash", "running", map[string]any{"command": "go test ./..."}, "", ""))
+ failing := toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "FAIL", "")
+ failing.Properties.(map[string]any)["part"].(map[string]any)["state"].(map[string]any)["metadata"] = map[string]any{"exitCode": 1}
+ writer.busEvent(failing)
+ writer.busEvent(failing)
+ writer.busEvent(assistantPayload("m1", "coder", 1, 2, 3, 0.01))
+
+ if len(host.stages) != 1 || host.stages[0] != "implement/running" {
+ t.Fatalf("stages = %v, want the one stage", host.stages)
+ }
+ if got := string(host.stageRecords[0].Data); got != `{"attempt":0}` {
+ t.Fatalf("stage data = %s, want the attempt", got)
+ }
+ if len(host.steps) != 1 || host.steps[0] != "bash: go test ./..." {
+ t.Fatalf("steps = %v, want the one finished call, once", host.steps)
+ }
+ if step := host.stepRecords[0]; step.Tool != "bash" || step.Step != StepExplore || step.Exit == nil || *step.Exit != 1 {
+ t.Fatalf("step = %+v, want the bash tool, the explore step and exit 1", step)
+ }
+ if !strings.Contains(notes.String(), `implement · running {"attempt":0}`) {
+ t.Fatalf("notes = %q, want the stage and its data for a person", notes.String())
+ }
+ if strings.Contains(notes.String(), "message.updated") || strings.Contains(notes.String(), "spend") {
+ t.Fatalf("notes carry bus traffic: %q", notes.String())
+ }
+}
diff --git a/internal/seniordev/app/full_verification.go b/internal/seniordev/app/full_verification.go
new file mode 100644
index 0000000000..541e174f34
--- /dev/null
+++ b/internal/seniordev/app/full_verification.go
@@ -0,0 +1,76 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification"
+)
+
+const fullVerificationTimeoutMS = 600_000
+
+// The registry may be configured with another shell. POSIX-like shells with
+// pipefail honor this strict mode; shells without it reject the preamble and
+// therefore fail verification closed instead of trusting a masked pipeline.
+const strictVerificationPreamble = "set -euo pipefail\n"
+
+type projectVerificationResult struct {
+ Commands []any
+ Prompt string
+ Failed *fullverification.Entrypoint
+ Failure string
+ // TimedOut is set when at least one entrypoint was killed at the
+ // fullVerificationTimeoutMS ceiling without ever producing an exit status.
+ // A hung suite is an INCOMPLETE observation, not a red one.
+ TimedOut bool
+ // NewFailures counts failures not excused as pre-existing (missing
+ // entrypoints included).
+ NewFailures int
+}
+
+// timedOutEntrypoint records an entrypoint that exhausted the verification ceiling,
+// together with the worktree fingerprint it hung against.
+type timedOutEntrypoint struct {
+ Tail string
+ Fingerprint string
+ HaveFinger bool
+}
+
+func verificationMemoKey(entrypoint fullverification.Entrypoint) string {
+ return entrypoint.Workdir + "\x00" + entrypoint.Command
+}
+
+// runProjectVerification executes the discovered project-wide entrypoints
+// through the live Bash registry. It deliberately disables the test memo
+// while retaining the registry's process-derived exitCode metadata.
+func (runner *pipeline) runProjectVerification(
+ ctx context.Context,
+) projectVerificationResult {
+ return newProjectVerificationRun(runner, ctx).run()
+}
+
+func planHasKind(plan fullverification.Plan, kind fullverification.EntrypointKind) bool {
+ for _, entrypoint := range plan.Entrypoints {
+ if entrypoint.Kind == kind {
+ return true
+ }
+ }
+ return false
+}
+
+func verificationTailSuffix(tail string) string {
+ if tail == "" {
+ return ""
+ }
+ return " — " + strings.ReplaceAll(tail, "\n", " ")
+}
+
+func verificationOutputTail(output string, limit int) string {
+ output = strings.TrimSpace(output)
+ if output == "" {
+ return ""
+ }
+ return suffixUTF16(output, limit)
+}
diff --git a/internal/seniordev/app/full_verification_run.go b/internal/seniordev/app/full_verification_run.go
new file mode 100644
index 0000000000..0388315a24
--- /dev/null
+++ b/internal/seniordev/app/full_verification_run.go
@@ -0,0 +1,369 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification"
+)
+
+type projectVerificationRun struct {
+ runner *pipeline
+ ctx context.Context
+ plan fullverification.Plan
+ result projectVerificationResult
+ lines []string
+ issues []string
+
+ currentFingerprint string
+ haveFingerprint bool
+ resolvedFingerprint bool
+}
+
+type verificationObservation struct {
+ entrypoint fullverification.Entrypoint
+ memoKey string
+ exitCode int
+ timedOut bool
+ tail string
+ evidence map[string]any
+ // suiteDead marks a failure whose output shows the suite aborted before
+ // running at all (verification_deadtree.go).
+ suiteDead bool
+ // safetyRegression also includes suite-local parser aborts (notably Jest),
+ // which are unsafe to accept even when unrelated suites still ran.
+ safetyRegression bool
+}
+
+func newProjectVerificationRun(runner *pipeline, ctx context.Context) *projectVerificationRun {
+ return &projectVerificationRun{
+ runner: runner,
+ ctx: ctx,
+ plan: fullverification.Discover(runner.workspace),
+ result: projectVerificationResult{Commands: []any{}},
+ lines: []string{
+ "# Independent full project verification",
+ "senior-dev independently discovered and ran the standard project entrypoints",
+ "below in fresh Bash subprocesses. These are process-derived command/exit",
+ "observations, not the model's claims. Consult them, but still run and cite",
+ "your own fresh verification commands.",
+ },
+ issues: []string{},
+ }
+}
+
+func (run *projectVerificationRun) run() projectVerificationResult {
+ // THE CHECK SAYS IT HAS STARTED, so a reader following the run knows
+ // senior-dev is running the project's build and tests itself before the
+ // first of them has finished. It reports; it decides nothing.
+ run.runner.events.stage("verification", "running", map[string]any{
+ "commands": len(run.plan.Entrypoints),
+ })
+ for _, entrypoint := range run.plan.Entrypoints {
+ observation := run.observe(entrypoint)
+ run.record(observation)
+ }
+ run.recordMissingEntrypoints()
+ vacuous := run.recordVacuousVerification()
+ return run.finish(vacuous)
+}
+
+// fingerprint resolves the tree fingerprint lazily and at most once per pass.
+// A run where nothing hangs must not pay for a scan.
+func (run *projectVerificationRun) fingerprint() (string, bool) {
+ if !run.resolvedFingerprint {
+ run.currentFingerprint, run.haveFingerprint = run.runner.worktreeFingerprint(run.ctx)
+ run.resolvedFingerprint = true
+ }
+ return run.currentFingerprint, run.haveFingerprint
+}
+
+func (run *projectVerificationRun) observe(
+ entrypoint fullverification.Entrypoint,
+) verificationObservation {
+ observation := verificationObservation{
+ entrypoint: entrypoint,
+ memoKey: verificationMemoKey(entrypoint),
+ exitCode: -1,
+ }
+ if run.replayPriorTimeout(&observation) {
+ observation.evidence = run.commandEvidence(observation)
+ return observation
+ }
+ run.execute(&observation)
+ run.updateTimeoutMemo(observation)
+ observation.evidence = run.commandEvidence(observation)
+ return observation
+}
+
+func (run *projectVerificationRun) replayPriorTimeout(
+ observation *verificationObservation,
+) bool {
+ prior, ok := run.runner.verificationTimeouts[observation.memoKey]
+ if !ok || !prior.HaveFinger {
+ return false
+ }
+ current, ok := run.fingerprint()
+ if !ok || current != prior.Fingerprint {
+ return false
+ }
+ observation.timedOut = true
+ observation.tail = prior.Tail
+ run.runner.note(fmt.Sprintf(
+ "[senior-dev] full verification %s: %s — replaying recorded timeout "+
+ "(tree unchanged since it hung; not paying the %ds ceiling again)\n",
+ observation.entrypoint.Kind, observation.entrypoint.Command,
+ fullVerificationTimeoutMS/1000,
+ ))
+ return true
+}
+
+func (run *projectVerificationRun) execute(observation *verificationObservation) {
+ entrypoint := observation.entrypoint
+ bashInput := map[string]any{
+ "command": strictVerificationPreamble + entrypoint.Command,
+ "timeout_ms": fullVerificationTimeoutMS,
+ }
+ if entrypoint.Workdir != "" {
+ bashInput["workdir"] = entrypoint.Workdir
+ }
+ input, _ := json.Marshal(bashInput)
+ toolResult, err := run.runner.runtime.registry.Execute(
+ run.ctx,
+ steploop.ToolCall{
+ ID: run.runner.runtime.nextID("verification"), Name: "bash", Input: input,
+ SessionID: run.runner.sessionID, Agent: "coder",
+ },
+ )
+ if err == nil {
+ observation.exitCode, observation.timedOut = verificationExit(toolResult.Metadata.Raw())
+ }
+ // A COMMAND CUT BY THE RUN'S OWN ENDING HAS NO EXIT STATUS. When the run's
+ // context ends while the project's commands run — codeaf's stop, or the
+ // wall clock — the command is killed half way, and what it left reads as a
+ // failure it never reported. It is recorded the way a hang is: an
+ // incomplete observation, never a red one, so neither is a submitted
+ // candidate failed nor an unsubmitted tree restored on its account. A
+ // command that had already exited clean before the stop keeps its pass.
+ if run.ctx.Err() != nil && observation.exitCode != 0 {
+ observation.exitCode, observation.timedOut = -1, true
+ }
+ output := toolResult.Output
+ if err != nil {
+ output = err.Error()
+ }
+ observation.tail = verificationOutputTail(output, 600)
+ // Suite-abort detection for the unsubmitted-tree finalizer
+ // (verification_deadtree.go).
+ if observation.exitCode != 0 && !observation.timedOut && suiteDeadOutput(output) {
+ observation.suiteDead = true
+ }
+ if observation.exitCode != 0 && !observation.timedOut && safetyRegressionOutput(output) {
+ observation.safetyRegression = true
+ }
+}
+
+func verificationExit(raw json.RawMessage) (int, bool) {
+ var metadata struct {
+ ExitCode *int `json:"exitCode"`
+ }
+ if json.Unmarshal(raw, &metadata) == nil && metadata.ExitCode != nil {
+ return *metadata.ExitCode, false
+ }
+ // bash.go omits exitCode on exactly one successful registry path: the
+ // timeout branch, where it kills the process group after the ceiling.
+ return -1, true
+}
+
+func (run *projectVerificationRun) updateTimeoutMemo(observation verificationObservation) {
+ if !observation.timedOut {
+ delete(run.runner.verificationTimeouts, observation.memoKey)
+ return
+ }
+ if run.runner.verificationTimeouts == nil {
+ run.runner.verificationTimeouts = map[string]timedOutEntrypoint{}
+ }
+ recorded, ok := run.fingerprint()
+ run.runner.verificationTimeouts[observation.memoKey] = timedOutEntrypoint{
+ Tail: observation.tail, Fingerprint: recorded, HaveFinger: ok,
+ }
+}
+
+func (run *projectVerificationRun) commandEvidence(
+ observation verificationObservation,
+) map[string]any {
+ entrypoint := observation.entrypoint
+ evidence := map[string]any{
+ "cmd": entrypoint.Command, "exit": float64(observation.exitCode),
+ "tail": observation.tail, "source": entrypoint.Source,
+ "kind": string(entrypoint.Kind), "buildExpected": run.plan.BuildExpected,
+ "testExpected": run.plan.TestExpected,
+ }
+ if entrypoint.Workdir != "" {
+ evidence["workdir"] = entrypoint.Workdir
+ }
+ if observation.timedOut {
+ evidence["timedOut"] = true
+ }
+ if observation.suiteDead {
+ evidence["suite_dead"] = true
+ }
+ if observation.safetyRegression {
+ evidence["safety_regression"] = true
+ }
+ return evidence
+}
+
+func (run *projectVerificationRun) record(observation verificationObservation) {
+ run.result.Commands = append(run.result.Commands, observation.evidence)
+ run.recordCommandLine(observation)
+ entrypoint := observation.entrypoint
+ // EACH COMMAND IS A STEP OF ITS OWN, reported after it ran and judged
+ // exactly as before: a command that hung, or that the run's own ending cut,
+ // has no exit to report.
+ var exit *int
+ if !observation.timedOut {
+ code := observation.exitCode
+ exit = &code
+ }
+ run.runner.events.verifyStep(entrypoint.Command, observation.tail, exit)
+ run.runner.note(fmt.Sprintf(
+ "[senior-dev] full verification %s: %s (exit=%d, source=%s)\n",
+ entrypoint.Kind, entrypoint.Command, observation.exitCode, entrypoint.Source,
+ ))
+ if observation.exitCode == 0 {
+ return
+ }
+ // Every non-zero exit is a failure, full stop. Excusing a red command as
+ // "pre-existing" on the strength of a pre-edit baseline probe would let a
+ // red baseline route every later red into the excused path, and the run
+ // would ship claiming it had verified. Whether an untouched test was
+ // already red is a question for the implement loop, on demand, at the
+ // moment of failure -- never a standing authority to ignore a failing
+ // command at ship time.
+ run.recordNewFailure(observation)
+}
+
+func (run *projectVerificationRun) recordCommandLine(observation verificationObservation) {
+ entrypoint := observation.entrypoint
+ if observation.timedOut {
+ run.lines = append(run.lines, fmt.Sprintf(
+ "- [%s] `%s` (source: %s) HUNG — killed at the %ds verification ceiling with no exit status%s",
+ entrypoint.Kind, entrypoint.Command, entrypoint.Source,
+ fullVerificationTimeoutMS/1000, verificationTailSuffix(observation.tail),
+ ))
+ return
+ }
+ run.lines = append(run.lines, fmt.Sprintf(
+ "- [%s] `%s` (source: %s) exit=%d%s",
+ entrypoint.Kind, entrypoint.Command, entrypoint.Source, observation.exitCode,
+ verificationTailSuffix(observation.tail),
+ ))
+}
+
+func (run *projectVerificationRun) recordNewFailure(observation verificationObservation) {
+ entrypoint := observation.entrypoint
+ if run.result.Failed == nil {
+ failed := entrypoint
+ run.result.Failed = &failed
+ }
+ run.result.NewFailures++
+ issue := fmt.Sprintf(
+ "project %s verification failed: `%s` exited %d",
+ entrypoint.Kind, entrypoint.Command, observation.exitCode,
+ )
+ if observation.timedOut {
+ run.result.TimedOut = true
+ issue = fmt.Sprintf(
+ "project %s verification did not complete: `%s` was killed after %ds "+
+ "(the verification ceiling) without producing an exit status — the suite "+
+ "hung, it did not report failures",
+ entrypoint.Kind, entrypoint.Command, fullVerificationTimeoutMS/1000,
+ )
+ }
+ if observation.tail != "" {
+ issue += ": " + observation.tail
+ }
+ run.issues = append(run.issues, issue)
+}
+
+func (run *projectVerificationRun) recordMissingEntrypoints() {
+ // Only demand entrypoints the discovered ecosystem is expected to have.
+ run.recordMissingEntrypoint(
+ run.plan.BuildExpected, fullverification.KindBuild,
+ "(project build/typecheck entrypoint not found)",
+ "project build/typecheck verification failed: no standard build/typecheck entrypoint was discoverable",
+ "- [build] no standard project build/typecheck entrypoint discovered",
+ )
+ run.recordMissingEntrypoint(
+ run.plan.TestExpected, fullverification.KindTest,
+ "(project test entrypoint not found)",
+ "project test verification failed: no standard test entrypoint was discoverable",
+ "- [test] no standard project test entrypoint discovered",
+ )
+}
+
+func (run *projectVerificationRun) recordMissingEntrypoint(
+ expected bool,
+ kind fullverification.EntrypointKind,
+ command string,
+ issue string,
+ line string,
+) {
+ if !expected || planHasKind(run.plan, kind) {
+ return
+ }
+ missing := fullverification.Entrypoint{
+ Kind: kind, Command: command, Source: "manifest/CI/documentation discovery",
+ }
+ if run.result.Failed == nil {
+ run.result.Failed = &missing
+ }
+ run.result.NewFailures++
+ run.issues = append(run.issues, issue)
+ run.lines = append(run.lines, line)
+}
+
+func (run *projectVerificationRun) recordVacuousVerification() bool {
+ vacuous := len(run.plan.Entrypoints) == 0 &&
+ !run.plan.BuildExpected && !run.plan.TestExpected
+ if !vacuous {
+ return false
+ }
+ run.lines = append(run.lines,
+ "- [none] no project build/typecheck or test entrypoint exists to discover:",
+ " this workspace carries no language manifest, build system, or test suite.",
+ " Full-project verification is VACUOUS here — it proves nothing.")
+ run.runner.note("[senior-dev] full project verification found nothing to run " +
+ "(no language manifest, build system, or test suite) — vacuous pass\n")
+ return true
+}
+
+func (run *projectVerificationRun) finish(vacuous bool) projectVerificationResult {
+ if len(run.issues) == 1 {
+ run.result.Failure = run.issues[0]
+ } else if len(run.issues) > 1 {
+ run.result.Failure = "project verification failed: " + strings.Join(run.issues, "; ")
+ }
+ run.result.Prompt = strings.Join(run.lines, "\n")
+ status := "pass"
+ data := map[string]any{"commands": run.result.Commands}
+ if vacuous {
+ data["vacuous"] = true
+ }
+ if run.result.Failed != nil {
+ status = "fail"
+ data["reason"] = run.result.Failure
+ }
+ run.runner.events.stage("verification", status, data)
+ // The result is remembered against the tree it measured, so finalize can
+ // consult the last verdict on an unchanged tree without re-verifying — the
+ // runs that need the dead-tree check end with no wall left to verify.
+ run.runner.rememberVerifiedTree(run.result)
+ return run.result
+}
diff --git a/internal/seniordev/app/full_verification_test.go b/internal/seniordev/app/full_verification_test.go
new file mode 100644
index 0000000000..7eb8bbe382
--- /dev/null
+++ b/internal/seniordev/app/full_verification_test.go
@@ -0,0 +1,221 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// These tests describe what senior-dev's own project verification guarantees.
+//
+// The family they belong to is "you cannot manufacture a green verification":
+// a run whose evidence array carries exit=1 commands must not report a pass,
+// and a project whose suite was never discovered is not a project that passed.
+
+func writePassingPythonUnitTest(t *testing.T, workspace string) {
+ t.Helper()
+ const source = `import unittest
+
+class GreenTest(unittest.TestCase):
+ def test_green(self):
+ self.assertEqual(2 + 2, 4)
+`
+ if err := writeFile(filepath.Join(workspace, "tests", "test_green.py"), source); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRedEntrypointAlwaysFailsVerification(t *testing.T) {
+ // A command that exits non-zero fails verification. Unconditionally.
+ //
+ // An escape hatch that excused a command because a pre-edit baseline probe
+ // had also seen it red would log the command as evidence but never count
+ // it, so NewFailures would stay 0 and the run would report a green
+ // verification while its own evidence array carried exit=1 commands.
+ //
+ // This test is the floor: a broken build is a failed verification no matter
+ // what the tree looked like before the first edit.
+ workspace := t.TempDir()
+ writePassingPythonUnitTest(t, workspace)
+ if err := writeFile(filepath.Join(workspace, "Makefile"),
+ "build:\n\texit 1\ntest:\n\ttrue\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ verification := runner.runProjectVerification(context.Background())
+ if verification.Failed == nil {
+ t.Fatal("a build exiting 1 must fail verification")
+ }
+ if verification.NewFailures == 0 {
+ t.Fatalf("NewFailures = 0 with a red build; the excused path is back")
+ }
+}
+
+func TestEveryRedVerificationCommandIsCounted(t *testing.T) {
+ // Every command recorded with a non-zero exit is counted as a new
+ // failure. A recorded red command that does not reach NewFailures means
+ // some caller has introduced a way to excuse a failure again.
+ workspace := t.TempDir()
+ writePassingPythonUnitTest(t, workspace)
+ if err := writeFile(filepath.Join(workspace, "Makefile"),
+ "build:\n\texit 1\ntest:\n\texit 1\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ verification := runner.runProjectVerification(context.Background())
+ red := 0
+ for _, command := range verification.Commands {
+ evidence, ok := command.(map[string]any)
+ if !ok {
+ continue
+ }
+ if exit, ok := evidence["exit"].(float64); ok && exit != 0 {
+ red++
+ }
+ }
+ if red == 0 {
+ t.Fatal("fixture produced no red command; the test proves nothing")
+ }
+ if verification.NewFailures < red {
+ t.Fatalf("NewFailures = %d but %d commands exited non-zero: "+
+ "some red command was recorded as evidence without being counted",
+ verification.NewFailures, red)
+ }
+}
+
+func verificationWorkspace(t *testing.T, files map[string]string) *pipeline {
+ t.Helper()
+ workspace := t.TempDir()
+ for name, content := range files {
+ if err := writeFile(filepath.Join(workspace, name), content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ t.Cleanup(runner.runtime.Close)
+ return runner
+}
+
+func TestShellControlFlowCannotManufactureGreenVerification(t *testing.T) {
+ // Six ways to make a red suite exit 0. Each of them turns "the tests pass"
+ // into "the shell returned zero", and a run that ships on that evidence has
+ // verified nothing. senior-dev executes discovered entrypoints under pipefail
+ // for exactly this reason.
+ for _, command := range []string{
+ "go test ./... || true",
+ "true || go test ./...",
+ "exit 0; go test ./...",
+ "true; go test ./...",
+ "go test ./... | cat",
+ "go test ./... 2>&1 | tee test.log",
+ } {
+ t.Run(command, func(t *testing.T) {
+ runner := verificationWorkspace(t, map[string]string{
+ "go.mod": "module example.test/red\n\ngo 1.23\n",
+ "red_test.go": "package red\n\nimport \"testing\"\n\n" +
+ "func TestRed(t *testing.T) { t.Fatal(\"red\") }\n",
+ "AGENTS.md": "Run `go build ./...` and `" + command + "`.\n",
+ })
+ if verification := runner.runProjectVerification(context.Background()); verification.Failed == nil {
+ t.Fatalf("control-flow bypass produced a green verification: %#v", verification)
+ }
+ })
+ }
+}
+
+func TestAProjectWhoseTestEntrypointWasNeverFoundDoesNotPass(t *testing.T) {
+ // The vacuous-green shape. A Go module is expected to have a test
+ // entrypoint; if discovery cannot find one, that is a failure with no
+ // failing COMMAND behind it -- Failed is set and the command list is empty.
+ //
+ // This is why soloShip reads verification.Failed rather than counting
+ // non-zero exits: counting commands would report a verified pass for a
+ // project whose suite was never located.
+ // A Makefile with a build target and nothing else: discovering any command
+ // makes the workspace accountable, and accountability is what demands a
+ // test entrypoint. A go.mod would defeat the fixture -- the Go ecosystem
+ // defaults supply `go test ./...` unprompted, so nothing would be missing.
+ runner := verificationWorkspace(t, map[string]string{
+ "Makefile": "build:\n\t@true\n",
+ })
+ verification := runner.runProjectVerification(context.Background())
+ if verification.Failed == nil {
+ t.Fatal("a project with no discoverable test entrypoint reported a green verification")
+ }
+ if !missingEntrypointFailure(verification) {
+ t.Fatalf("expected a discovery failure, got %#v", verification.Failed)
+ }
+ if countFailingEntrypoints(verification) != 0 {
+ t.Fatal("fixture no longer isolates the missing-entrypoint case from failing commands")
+ }
+}
+
+func TestABareWorkspaceVerifiesVacuouslyRatherThanFailing(t *testing.T) {
+ // The complement, and the reason the check above is Failed rather than
+ // "did we run anything". A directory with no manifest, build system or
+ // suite has nothing to verify. Demanding entrypoints there would fail every
+ // documentation-only task on principle.
+ runner := verificationWorkspace(t, map[string]string{"NOTES.txt": "no build system here\n"})
+ verification := runner.runProjectVerification(context.Background())
+ if verification.Failed != nil {
+ t.Fatalf("a bare workspace was failed for having nothing to run: %#v", verification.Failed)
+ }
+ if !strings.Contains(verification.Prompt, "VACUOUS") {
+ t.Fatalf("a vacuous pass must say so in its evidence:\n%s", verification.Prompt)
+ }
+}
+
+// EVERY COMMAND senior-dev RUNS ON THE TREE ITSELF IS A STEP OF ITS OWN — the
+// verify step, the bash tool, the command, its exit code and the tail of what
+// it printed — reported after it ran, and judged exactly as before.
+func TestEachVerificationCommandIsReportedAsAVerifyStep(t *testing.T) {
+ workspace := t.TempDir()
+ writePassingPythonUnitTest(t, workspace)
+ if err := writeFile(filepath.Join(workspace, "Makefile"),
+ "build:\n\t@echo broken; exit 2\ntest:\n\t@true\n"); err != nil {
+ t.Fatal(err)
+ }
+ host := &recordedHost{}
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newRecordWriter(host, io.Discard), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ verification := runner.runProjectVerification(context.Background())
+ if len(host.stepRecords) != len(verification.Commands) || len(host.stepRecords) == 0 {
+ t.Fatalf("steps = %d for %d commands, want one each", len(host.stepRecords), len(verification.Commands))
+ }
+ failed := false
+ for i, step := range host.stepRecords {
+ evidence := verification.Commands[i].(map[string]any)
+ if step.Step != StepVerify || step.Tool != "bash" || step.Command != "bash: "+evidence["cmd"].(string) {
+ t.Fatalf("step %d = %+v, want the verify step for %v", i, step, evidence["cmd"])
+ }
+ if step.Exit == nil || float64(*step.Exit) != evidence["exit"].(float64) {
+ t.Fatalf("step %d exit = %v, want the evidence's %v", i, step.Exit, evidence["exit"])
+ }
+ if *step.Exit == 2 && strings.Contains(step.Observation, "broken") {
+ failed = true
+ }
+ }
+ if !failed || verification.Failed == nil {
+ t.Fatalf("the failing build is not a verify step with its exit and tail: %+v", host.stepRecords)
+ }
+ if first, last := host.stages[0], host.stages[len(host.stages)-1]; first != "verification/running" || last != "verification/fail" {
+ t.Fatalf("the stages are %v, want the check's start before its steps and its own result after them", host.stages)
+ }
+}
diff --git a/internal/seniordev/app/gitrepo_test.go b/internal/seniordev/app/gitrepo_test.go
new file mode 100644
index 0000000000..7796aa055c
--- /dev/null
+++ b/internal/seniordev/app/gitrepo_test.go
@@ -0,0 +1,57 @@
+//go:build !windows
+
+package app
+
+import (
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+// gitTestRepo is a one-commit repository with a pipeline pointed at it, for
+// tests that exercise the tree helpers without a model or a runtime.
+func gitTestRepo(t *testing.T) *pipeline {
+ t.Helper()
+ dir := t.TempDir()
+ git := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+ git("init", "-q")
+ if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git("add", "-A")
+ git("commit", "-q", "-m", "base")
+ return &pipeline{
+ workspace: dir, events: newEventWriter(io.Discard), notes: io.Discard,
+ recorder: newGitRecorder(dir, func(string) {}),
+ }
+}
+
+// verificationWith is a completed verification with the given number of
+// failing entrypoints plus one passing one.
+func verificationWith(failing int) projectVerificationResult {
+ commands := []any{}
+ for i := 0; i < failing; i++ {
+ commands = append(commands, map[string]any{"exit": float64(1)})
+ }
+ commands = append(commands, map[string]any{"exit": float64(0)})
+ return projectVerificationResult{Commands: commands}
+}
+
+func writeWorkspace(t *testing.T, runner *pipeline, name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(runner.workspace, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/seniordev/app/ignore.go b/internal/seniordev/app/ignore.go
new file mode 100644
index 0000000000..25dcdb6627
--- /dev/null
+++ b/internal/seniordev/app/ignore.go
@@ -0,0 +1,197 @@
+//go:build !windows
+
+package app
+
+import (
+ "bufio"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// A .gitignore reader for the snapshot recorder. Under the git recorder this
+// file is dead weight: git answers "what belongs to the tree" itself. Without
+// git something has to, and answering it wrongly is not a cosmetic bug -- an
+// over-broad ignore silently drops the model's work out of the answer, and an
+// under-broad one sweeps build output into it.
+//
+// WHAT IT IMPLEMENTS: blank lines and #comments; a leading ! negation; a
+// trailing / restricting a pattern to directories; a leading or embedded /
+// anchoring a pattern to the file it came from; *, ? and ** globs; per-
+// directory .gitignore files, where a deeper file's rules win over a shallower
+// one's, and a later rule in one file wins over an earlier one.
+//
+// WHAT IT DOES NOT: .git/info/exclude, core.excludesFile, .gitattributes,
+// nested repositories, or character classes. Those are real gitignore features
+// this deliberately skips. `senior-dev run` without --in-place uses git and is
+// unaffected; the limits are documented in ARCHITECTURE.md so an in-place run
+// on a repository that leans on them is a known, visible gap rather than a
+// surprise.
+type ignoreRules struct {
+ // byDir maps a directory (slash-separated, relative to the workspace, ""
+ // for the root) to the rules its own .gitignore declared.
+ byDir map[string][]ignoreRule
+}
+
+type ignoreRule struct {
+ pattern *regexp.Regexp
+ negate bool
+ dirOnly bool
+ anchored bool
+ source string // the directory the rule came from
+}
+
+func newIgnoreRules() *ignoreRules {
+ return &ignoreRules{byDir: map[string][]ignoreRule{}}
+}
+
+// load reads the .gitignore in one directory, if it has one. dir is relative
+// to the workspace, slash-separated, "" at the root.
+func (rules *ignoreRules) load(workspace, dir string) {
+ name := filepath.Join(workspace, filepath.FromSlash(dir), ".gitignore")
+ file, err := os.Open(name)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+ var parsed []ignoreRule
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ if rule, ok := parseIgnoreLine(scanner.Text(), dir); ok {
+ parsed = append(parsed, rule)
+ }
+ }
+ if len(parsed) > 0 {
+ rules.byDir[dir] = parsed
+ }
+}
+
+func parseIgnoreLine(line, dir string) (ignoreRule, bool) {
+ trimmed := strings.TrimRight(line, " \t")
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return ignoreRule{}, false
+ }
+ rule := ignoreRule{source: dir}
+ if strings.HasPrefix(trimmed, "!") {
+ rule.negate = true
+ trimmed = trimmed[1:]
+ }
+ if strings.HasSuffix(trimmed, "/") {
+ rule.dirOnly = true
+ trimmed = strings.TrimSuffix(trimmed, "/")
+ }
+ if trimmed == "" {
+ return ignoreRule{}, false
+ }
+ // A pattern containing a slash anywhere but at its end is anchored to the
+ // directory its .gitignore sits in; one without is matched against every
+ // path component below that directory.
+ rule.anchored = strings.Contains(trimmed, "/")
+ trimmed = strings.TrimPrefix(trimmed, "/")
+ rule.pattern = compileIgnoreGlob(trimmed)
+ return rule, rule.pattern != nil
+}
+
+// compileIgnoreGlob turns a gitignore glob into an anchored regexp. ** spans
+// separators, * and ? do not.
+func compileIgnoreGlob(glob string) *regexp.Regexp {
+ var builder strings.Builder
+ builder.WriteString("^")
+ for index := 0; index < len(glob); index++ {
+ switch glob[index] {
+ case '*':
+ if index+1 < len(glob) && glob[index+1] == '*' {
+ builder.WriteString(".*")
+ index++
+ // A trailing separator after ** is optional, so "a/**" matches
+ // "a" as well as "a/b".
+ if index+1 < len(glob) && glob[index+1] == '/' {
+ index++
+ }
+ continue
+ }
+ builder.WriteString("[^/]*")
+ case '?':
+ builder.WriteString("[^/]")
+ default:
+ builder.WriteString(regexp.QuoteMeta(string(glob[index])))
+ }
+ }
+ builder.WriteString("$")
+ compiled, err := regexp.Compile(builder.String())
+ if err != nil {
+ return nil
+ }
+ return compiled
+}
+
+// ignored reports whether a path is excluded. relative is slash-separated and
+// relative to the workspace. The deepest .gitignore that has an opinion wins,
+// and within one file the last matching rule wins -- which is what makes a
+// negation able to rescue a path an earlier rule excluded.
+func (rules *ignoreRules) ignored(relative string, isDir bool) bool {
+ decided, excluded := false, false
+ // Shallowest first, so a deeper directory's rules overwrite the decision.
+ for _, dir := range ancestorDirs(relative) {
+ for _, rule := range rules.byDir[dir] {
+ if rule.dirOnly && !isDir {
+ continue
+ }
+ if rule.matches(relative, dir) {
+ decided, excluded = true, !rule.negate
+ }
+ }
+ }
+ if !decided {
+ return false
+ }
+ return excluded
+}
+
+func (rule ignoreRule) matches(relative, dir string) bool {
+ within := relative
+ if dir != "" {
+ within = strings.TrimPrefix(relative, dir+"/")
+ if within == relative {
+ return false
+ }
+ }
+ if rule.anchored {
+ return rule.pattern.MatchString(within)
+ }
+ // Unanchored: the pattern applies to any component, and to any directory
+ // prefix, so "build" excludes "build" and everything under it.
+ for {
+ if rule.pattern.MatchString(within) {
+ return true
+ }
+ parent := path.Dir(within)
+ if parent == "." || parent == within {
+ return false
+ }
+ within = parent
+ }
+}
+
+// ancestorDirs lists the directories whose .gitignore can speak about a path,
+// shallowest first: "", then each parent, excluding the path itself.
+func ancestorDirs(relative string) []string {
+ dirs := []string{""}
+ parent := path.Dir(relative)
+ if parent == "." || parent == "/" {
+ return dirs
+ }
+ parts := strings.Split(parent, "/")
+ current := ""
+ for _, part := range parts {
+ if current == "" {
+ current = part
+ } else {
+ current += "/" + part
+ }
+ dirs = append(dirs, current)
+ }
+ return dirs
+}
diff --git a/internal/seniordev/app/model_request_events.go b/internal/seniordev/app/model_request_events.go
new file mode 100644
index 0000000000..a2a57f59da
--- /dev/null
+++ b/internal/seniordev/app/model_request_events.go
@@ -0,0 +1,236 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unicode/utf8"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+)
+
+// This is an event-only observer. No field is persisted into model messages,
+// used for routing, or read by the budget/exit machinery. IDs are process-local
+// and deliberately do not consume the model-message ID generator.
+type modelRequestEvent struct {
+ RequestID string `json:"requestID"`
+ SessionID string `json:"sessionID"`
+ Agent string `json:"agent"`
+ RequestedProvider string `json:"requestedProvider"`
+ RequestedModel string `json:"requestedModel"`
+ Phase string `json:"phase"`
+ Status string `json:"status,omitempty"`
+ ErrorStage string `json:"errorStage,omitempty"`
+ ResponseID string `json:"responseID,omitempty"`
+ ServedModel string `json:"servedModel,omitempty"`
+ Provider string `json:"provider,omitempty"`
+ FinishReason string `json:"finishReason,omitempty"`
+ ElapsedMS int64 `json:"elapsedMS"`
+ FirstDeltaMS *int64 `json:"firstDeltaMS,omitempty"`
+ LastDeltaMS *int64 `json:"lastDeltaMS,omitempty"`
+ TextCharacters int64 `json:"textCharacters"`
+ ReasoningCharacters int64 `json:"reasoningCharacters"`
+ SubstantiveDeltas int64 `json:"substantiveDeltas"`
+}
+
+type modelRequestSink func(modelRequestEvent)
+
+var modelRequestEventDefinition = bus.Define("session.model.request", modelRequestEvent{})
+var modelRequestSequence atomic.Uint64
+
+func newModelRequestSink(instance *bus.Bus) modelRequestSink {
+ if instance == nil {
+ return nil
+ }
+ return func(event modelRequestEvent) { instance.Publish(modelRequestEventDefinition, event) }
+}
+
+type modelRequestObservation struct {
+ mu sync.Mutex
+ ctx context.Context
+ sink modelRequestSink
+ event modelRequestEvent
+ start time.Time
+ end time.Time
+ done bool
+ streamError error
+ providerError bool
+ aborted bool
+ sawEOF bool
+ sawFinish bool
+}
+
+func beginModelRequest(ctx context.Context, sink modelRequestSink, session, agent, provider, model string) *modelRequestObservation {
+ if sink == nil {
+ return nil
+ }
+ o := &modelRequestObservation{ctx: ctx, sink: sink, start: time.Now(), event: modelRequestEvent{
+ RequestID: fmt.Sprintf("request-%d", modelRequestSequence.Add(1)),
+ SessionID: modelRequestLabel(session), Agent: modelRequestLabel(agent),
+ RequestedProvider: modelRequestLabel(provider), RequestedModel: modelRequestLabel(model),
+ Phase: "begin",
+ }}
+ o.emit(o.event)
+ return o
+}
+
+// Provider-origin fields are bounded identifiers, never arbitrary metadata.
+// Reject rather than truncate malformed values, so they cannot resemble a valid
+// generation ID after clipping. No raw error, header, usage object, text, tool
+// argument, annotation, or reasoning record enters this event.
+func modelRequestLabel(value string) string {
+ if len(value) > 200 {
+ return ""
+ }
+ for _, c := range value {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
+ c == '-' || c == '_' || c == '.' || c == '/' || c == ':' || c == ' ' || c == '(' || c == ')') {
+ return ""
+ }
+ }
+ return value
+}
+
+func modelRequestFinish(value string) string {
+ switch value {
+ case "stop", "length", "tool-calls", "content-filter", "error", "other", "unknown":
+ return value
+ default:
+ return "unknown"
+ }
+}
+
+func (o *modelRequestObservation) observe(part orclient.StreamPart, err error) {
+ if o == nil {
+ return
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.done {
+ return
+ }
+ switch value := part.(type) {
+ case orclient.TextDeltaPart:
+ o.recordDelta(value.Delta, false)
+ case orclient.ReasoningDeltaPart:
+ o.recordDelta(value.Delta, true)
+ case orclient.ResponseMetadataPart:
+ if value.IsModel {
+ o.event.ServedModel = modelRequestLabel(value.ModelID)
+ } else {
+ o.event.ResponseID = modelRequestLabel(value.ID)
+ }
+ case orclient.FinishPart:
+ o.sawFinish = true
+ o.event.FinishReason = modelRequestFinish(value.FinishReason.Unified)
+ if value.Metadata.Provider != nil {
+ o.event.Provider = modelRequestLabel(*value.Metadata.Provider)
+ }
+ if o.end.IsZero() {
+ o.end = time.Now()
+ }
+ case orclient.ErrorPart:
+ o.providerError = true
+ if o.end.IsZero() {
+ o.end = time.Now()
+ }
+ case orclient.AbortPart:
+ o.aborted = true
+ if o.end.IsZero() {
+ o.end = time.Now()
+ }
+ }
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ o.sawEOF = true
+ } else if o.streamError == nil {
+ o.streamError = err
+ }
+ if o.end.IsZero() {
+ o.end = time.Now()
+ }
+ }
+}
+
+// Called under mu; count Unicode code points without retaining content.
+// Empty deltas and tool-input deltas are deliberately not substantive here.
+func (o *modelRequestObservation) recordDelta(value string, reasoning bool) {
+ if value == "" {
+ return
+ }
+ elapsed := time.Since(o.start).Milliseconds()
+ if o.event.FirstDeltaMS == nil {
+ first := elapsed
+ o.event.FirstDeltaMS = &first
+ }
+ o.event.LastDeltaMS = &elapsed
+ o.event.SubstantiveDeltas++
+ if reasoning {
+ o.event.ReasoningCharacters += int64(utf8.RuneCountInString(value))
+ } else {
+ o.event.TextCharacters += int64(utf8.RuneCountInString(value))
+ }
+}
+
+// Close is the ownership boundary, so it emits the one final observation even
+// for a caller that abandons a canceled stream without reading its last part.
+// Delay publication until Close to include cleanup errors; elapsed time ends at
+// the first stream terminal observation, excluding subsequent tool settlement.
+func (o *modelRequestObservation) finish(stage string, err error) {
+ if o == nil {
+ return
+ }
+ o.mu.Lock()
+ if o.done {
+ o.mu.Unlock()
+ return
+ }
+ o.done = true
+ if o.end.IsZero() {
+ o.end = time.Now()
+ }
+ event := o.event
+ event.Phase = "end"
+ event.ElapsedMS = o.end.Sub(o.start).Milliseconds()
+ if o.streamError != nil {
+ err, stage = o.streamError, "stream"
+ }
+ switch {
+ case errors.Is(err, context.DeadlineExceeded):
+ event.Status, event.ErrorStage = "deadline", stage
+ case errors.Is(err, context.Canceled):
+ event.Status, event.ErrorStage = "canceled", stage
+ case err != nil:
+ event.Status, event.ErrorStage = "error", stage
+ case o.providerError:
+ event.Status, event.ErrorStage = "provider-error", "stream"
+ case o.aborted:
+ event.Status, event.ErrorStage = "aborted", "stream"
+ case o.sawFinish:
+ event.Status = "finished"
+ case errors.Is(o.ctx.Err(), context.DeadlineExceeded):
+ event.Status, event.ErrorStage = "deadline", "close"
+ case errors.Is(o.ctx.Err(), context.Canceled):
+ event.Status, event.ErrorStage = "canceled", "close"
+ case o.sawEOF:
+ event.Status = "eof-without-finish"
+ default:
+ event.Status = "closed-without-finish"
+ }
+ o.mu.Unlock()
+ o.emit(event)
+}
+
+func (o *modelRequestObservation) emit(event modelRequestEvent) {
+ // Optional telemetry failures must never change model success, errors, or
+ // cleanup. The concrete sink only publishes to the existing local bus.
+ defer func() { _ = recover() }()
+ o.sink(event)
+}
diff --git a/internal/seniordev/app/model_request_events_test.go b/internal/seniordev/app/model_request_events_test.go
new file mode 100644
index 0000000000..11fcb24f39
--- /dev/null
+++ b/internal/seniordev/app/model_request_events_test.go
@@ -0,0 +1,304 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+)
+
+type observedFakeStream struct {
+ parts []orclient.StreamPart
+ err, closeErr error
+ closed int
+}
+
+func (s *observedFakeStream) Next() (orclient.StreamPart, error) {
+ if len(s.parts) == 0 {
+ return nil, s.err
+ }
+ p := s.parts[0]
+ s.parts = s.parts[1:]
+ return p, nil
+}
+func (s *observedFakeStream) Close() error { s.closed++; return s.closeErr }
+
+func TestModelRequestObservationOnceOnly(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ parts []orclient.StreamPart
+ err, closeErr error
+ cancel bool
+ status, stage string
+ }{
+ {"normal", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}}}, io.EOF, nil, false, "finished", ""},
+ {"tool", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "tool-calls"}}}, io.EOF, nil, false, "finished", ""},
+ {"failed", nil, errors.New("private transport message"), nil, false, "error", "stream"},
+ {"provider-error", []orclient.StreamPart{orclient.ErrorPart{Error: json.RawMessage(`{"message":"secret"}`)}}, io.EOF, nil, false, "provider-error", "stream"},
+ {"canceled-read", nil, context.Canceled, nil, false, "canceled", "stream"},
+ {"deadline-read", nil, context.DeadlineExceeded, nil, false, "deadline", "stream"},
+ {"canceled-close", nil, io.EOF, nil, true, "canceled", "close"},
+ {"close-error", nil, io.EOF, errors.New("private close message"), false, "error", "close"},
+ {"finish-close-error", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}}}, io.EOF, errors.New("private close message"), false, "error", "close"},
+ {"abort", []orclient.StreamPart{orclient.AbortPart{}}, io.EOF, nil, false, "aborted", "stream"},
+ {"eof", nil, io.EOF, nil, false, "eof-without-finish", ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ var events []modelRequestEvent
+ o := beginModelRequest(ctx, func(e modelRequestEvent) { events = append(events, e) }, "ses-test", "coder", "openrouter", "vendor/model")
+ inner := &observedFakeStream{parts: append([]orclient.StreamPart{}, tc.parts...), err: tc.err, closeErr: tc.closeErr}
+ ledger := &turnLedger{}
+ stream := &costPartStream{inner: inner, ledger: ledger, call: ledger.begin(false), observation: o}
+ for range tc.parts {
+ if _, err := stream.Next(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := stream.Next(); err != tc.err {
+ t.Fatalf("Next error changed: %v", err)
+ }
+ if tc.cancel {
+ cancel()
+ }
+ for range 2 {
+ if err := stream.Close(); err != tc.closeErr {
+ t.Fatalf("Close error changed: %v", err)
+ }
+ }
+ o.finish("begin", errors.New("duplicate"))
+ if len(events) != 2 || events[0].Phase != "begin" || events[1].Phase != "end" || events[0].RequestID != events[1].RequestID || events[1].Status != tc.status || events[1].ErrorStage != tc.stage {
+ t.Fatalf("events = %+v", events)
+ }
+ if inner.closed != 2 {
+ t.Fatalf("underlying Close calls changed: %d", inner.closed)
+ }
+ raw, _ := json.Marshal(events)
+ if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "secret") {
+ t.Fatalf("error leaked: %s", raw)
+ }
+ })
+ }
+}
+
+func TestModelRequestMetadataWhitelistAndClock(t *testing.T) {
+ var events []modelRequestEvent
+ o := beginModelRequest(context.Background(), func(e modelRequestEvent) { events = append(events, e) }, "ses", "compaction", "openrouter", "vendor/model")
+ provider := "Provider (fast)"
+ for range 2 {
+ o.observe(orclient.ResponseMetadataPart{ID: "gen-123"}, nil)
+ o.observe(orclient.ResponseMetadataPart{ModelID: "vendor/served", IsModel: true}, nil)
+ }
+ o.observe(orclient.TextDeltaPart{Delta: "PRIVATE PROMPT"}, nil)
+ o.observe(orclient.ReasoningDeltaPart{Delta: "思考"}, nil)
+ o.observe(orclient.TextDeltaPart{Delta: ""}, nil)
+ o.observe(orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}, Metadata: orclient.OpenRouterMetadata{Provider: &provider}}, nil)
+ // Pin clock boundaries directly: tool settlement / Close must not count.
+ o.start = time.Unix(10, 0)
+ o.end = o.start.Add(1234 * time.Millisecond)
+ o.finish("close", nil)
+ end := events[1]
+ if end.ResponseID != "gen-123" || end.ServedModel != "vendor/served" || end.Provider != provider || end.Agent != "compaction" || end.ElapsedMS != 1234 {
+ t.Fatalf("end=%+v", end)
+ }
+ if end.TextCharacters != 14 || end.ReasoningCharacters != 2 || end.SubstantiveDeltas != 2 || end.FirstDeltaMS == nil || end.LastDeltaMS == nil || *end.LastDeltaMS < *end.FirstDeltaMS {
+ t.Fatalf("delta counters = %+v", end)
+ }
+ raw, _ := json.Marshal(events)
+ if strings.Contains(string(raw), "PRIVATE") {
+ t.Fatal("text leaked")
+ }
+ for _, value := range []string{strings.Repeat("x", 201), "line\nbreak", "{\"secret\":1}", "credential=secret"} {
+ if modelRequestLabel(value) != "" {
+ t.Fatalf("unsafe label accepted: %q", value)
+ }
+ }
+ if modelRequestFinish("raw private reason") != "unknown" {
+ t.Fatal("raw finish leaked")
+ }
+ second := beginModelRequest(context.Background(), func(modelRequestEvent) {}, "ses", "coder", "openrouter", "model")
+ if second.event.RequestID == o.event.RequestID {
+ t.Fatal("correlation reused")
+ }
+}
+
+// Real request assembly and HTTP transport, without sockets or model calls.
+// Compare all outbound bytes/headers and returned stream parts with nil,
+// recording, and panicking sinks, for both coder and actual summary clients.
+func TestModelRequestTelemetryLeavesWireAndResultsUnchanged(t *testing.T) {
+ for _, summary := range []bool{false, true} {
+ for _, reply := range []string{chatReply("answer", 10), toolCallReply("bash", `{"command":"true"}`)} {
+ var baseBody []byte
+ var baseHeader http.Header
+ var baseParts []string
+ var baseCalls []turnCall
+ for mode := 0; mode < 3; mode++ {
+ var body []byte
+ var header http.Header
+ var events []modelRequestEvent
+ backend := &modelAPIBackend{api: testModelAPI, variant: "high", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ body, _ = io.ReadAll(r.Body)
+ header = r.Header.Clone()
+ // Metadata already in OpenRouter's supported stream format.
+ prefix := "data: {\"id\":\"gen-123\",\"model\":\"vendor/served\",\"provider\":\"Provider A\",\"choices\":[]}\n\n"
+ return recordedResponse(r, 200, "text/event-stream", prefix+reply), nil
+ })}}
+ ledger := &turnLedger{}
+ client := newSeniorDevLLM(backend, "ses-fixed", "openrouter", "vendor/model", "coder", "high", nil, ledger, false)
+ if mode == 1 {
+ client.modelRequests = func(e modelRequestEvent) { events = append(events, e) }
+ }
+ if mode == 2 {
+ client.modelRequests = func(modelRequestEvent) { panic("telemetry unavailable") }
+ }
+ params := orclient.RequestParams{ModelID: "vendor/model", Prompt: []msgmodel.ModelMessage{msgmodel.UserText("PRIVATE TASK")}}
+ var stream steploop.PartStream
+ var err error
+ if summary {
+ stream, err = (seniorDevSummaryClient{owner: client}).Stream(context.Background(), params)
+ } else {
+ stream, err = client.Stream(context.Background(), params)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var parts []string
+ for {
+ p, e := stream.Next()
+ if e == io.EOF {
+ break
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ raw, _ := json.Marshal(p)
+ parts = append(parts, string(raw))
+ }
+ if err := stream.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if mode == 0 {
+ baseBody = body
+ baseHeader = header
+ baseParts = parts
+ baseCalls = ledger.snapshot()
+ } else if !reflect.DeepEqual(body, baseBody) || !reflect.DeepEqual(header, baseHeader) || !reflect.DeepEqual(parts, baseParts) || !reflect.DeepEqual(ledger.snapshot(), baseCalls) {
+ t.Fatalf("telemetry changed wire/parts/costs: summary=%v mode=%d", summary, mode)
+ }
+ if mode == 1 {
+ if len(events) != 2 || events[1].Provider != "Provider A" || events[1].ServedModel != "vendor/served" || events[1].Status != "finished" {
+ t.Fatalf("events=%+v", events)
+ }
+ wantAgent := "coder"
+ if summary {
+ wantAgent = "compaction"
+ }
+ if events[1].Agent != wantAgent {
+ t.Fatal("wrong agent")
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestModelRequestBeginFailureAndCancellation(t *testing.T) {
+ for _, failure := range []error{errors.New("PRIVATE HTTP FAILURE"), context.Canceled, context.DeadlineExceeded} {
+ var events []modelRequestEvent
+ backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, failure })}}
+ client := newSeniorDevLLM(backend, "ses", "openrouter", "vendor/model", "coder", "", nil, &turnLedger{}, false)
+ client.modelRequests = func(e modelRequestEvent) { events = append(events, e) }
+ _, err := client.Stream(context.Background(), orclient.RequestParams{})
+ if err == nil || len(events) != 2 || events[1].ErrorStage != "begin" {
+ t.Fatalf("err=%v events=%+v", err, events)
+ }
+ want := "error"
+ if failure == context.Canceled {
+ want = "canceled"
+ }
+ if failure == context.DeadlineExceeded {
+ want = "deadline"
+ }
+ if events[1].Status != want {
+ t.Fatalf("status=%s want %s", events[1].Status, want)
+ }
+ raw, _ := json.Marshal(events)
+ if strings.Contains(string(raw), "PRIVATE") {
+ t.Fatal("raw error leaked")
+ }
+ }
+}
+
+func TestModelRequestCanceledBeforeReadAndNilSinkClose(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ var events []modelRequestEvent
+ o := beginModelRequest(ctx, func(e modelRequestEvent) { events = append(events, e) }, "ses", "coder", "openrouter", "model")
+ cancel()
+ inner := &observedFakeStream{}
+ stream := &costPartStream{inner: inner, observation: o}
+ if err := stream.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 || events[1].Status != "canceled" {
+ t.Fatalf("events=%+v", events)
+ }
+ plain := &costPartStream{inner: inner}
+ if err := plain.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestModelRequestRuntimeWiring(t *testing.T) {
+ backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ return recordedResponse(r, 200, "text/event-stream", chatReply("done", 10)), nil
+ })}}
+ runtime := newRuntime(t.TempDir(), backend)
+ defer runtime.Close()
+ var events []modelRequestEvent
+ runtime.bus.SubscribeCallback(modelRequestEventDefinition, func(p bus.Payload) { events = append(events, p.Properties.(modelRequestEvent)) })
+ result, err := runtime.runTurn(context.Background(), turn{Agent: "coder", ProviderID: "openrouter", ModelID: "vendor/model", Prompt: "PRIVATE TASK", RawModelCall: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "done" || len(events) != 2 || events[1].Status != "finished" || events[1].SessionID != result.SessionID {
+ t.Fatalf("result=%+v events=%+v", result, events)
+ }
+}
+
+func TestModelRequestBusSink(t *testing.T) {
+ if newModelRequestSink(nil) != nil || beginModelRequest(context.Background(), nil, "", "", "", "") != nil {
+ t.Fatal("nil sink not inert")
+ }
+ instance := bus.New(bus.Context{})
+ var got []bus.Payload
+ instance.SubscribeCallback(modelRequestEventDefinition, func(p bus.Payload) { got = append(got, p) })
+ o := beginModelRequest(context.Background(), newModelRequestSink(instance), "ses", "coder", "openrouter", "model")
+ o.finish("close", nil)
+ if len(got) != 2 || got[0].Type != "session.model.request" {
+ t.Fatalf("got=%+v", got)
+ }
+}
+
+func TestModelRequestResolutionFailure(t *testing.T) {
+ var events []modelRequestEvent
+ backend := &modelAPIBackend{catalog: seniorDevCatalogFixture(t)}
+ client := newSeniorDevLLM(backend, "ses", "openrouter", "missing/model", "coder", "", nil, &turnLedger{}, false)
+ client.modelRequests = func(e modelRequestEvent) { events = append(events, e) }
+ _, err := client.Stream(context.Background(), orclient.RequestParams{})
+ if err == nil || len(events) != 2 || events[1].Status != "error" || events[1].ErrorStage != "resolve" {
+ t.Fatalf("err=%v events=%+v", err, events)
+ }
+}
diff --git a/internal/seniordev/app/netpolicy_visibility_test.go b/internal/seniordev/app/netpolicy_visibility_test.go
new file mode 100644
index 0000000000..36b5ca6fb4
--- /dev/null
+++ b/internal/seniordev/app/netpolicy_visibility_test.go
@@ -0,0 +1,19 @@
+//go:build !windows
+
+package app
+
+import (
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy"
+)
+
+func TestPolicyDisabledTools(t *testing.T) {
+ if names := policyDisabledTools(netpolicy.Policy{Mode: netpolicy.ModeAllow}); names != nil {
+ t.Fatalf("allow mode should disable nothing, got %v", names)
+ }
+ names := policyDisabledTools(netpolicy.Policy{Mode: netpolicy.ModeOff})
+ if len(names) != 2 || names[0] != "webfetch" || names[1] != "websearch" {
+ t.Fatalf("off mode should hide both web tools, got %v", names)
+ }
+}
diff --git a/internal/seniordev/app/patch_contract.go b/internal/seniordev/app/patch_contract.go
new file mode 100644
index 0000000000..d5bcaa74aa
--- /dev/null
+++ b/internal/seniordev/app/patch_contract.go
@@ -0,0 +1,24 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+)
+
+type countingWriter int64
+
+func (writer *countingWriter) Write(value []byte) (int, error) {
+ *writer += countingWriter(len(value))
+ return len(value), nil
+}
+
+// emitPatchSummary records the shape of the run's final diff against the base
+// commit -- files, line counts, binaries, patch bytes, untracked files -- on
+// the event stream. It is observational only: nothing in the run acts on it.
+func (runner *pipeline) emitPatchSummary(baseSHA string) {
+ ctx, cancel := context.WithTimeout(context.Background(), summaryTimeout)
+ defer cancel()
+ data, status := runner.recorder.Summary(ctx, baseSHA)
+ runner.events.stage("patch-summary", status, data)
+}
diff --git a/internal/seniordev/app/patch_contract_test.go b/internal/seniordev/app/patch_contract_test.go
new file mode 100644
index 0000000000..1d2a7170d9
--- /dev/null
+++ b/internal/seniordev/app/patch_contract_test.go
@@ -0,0 +1,38 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestSafeSeniorDevEnvironmentRedactsSecretLikeValues(t *testing.T) {
+ t.Setenv("SENIOR_DEV_NET", "off")
+ t.Setenv("SENIOR_DEV_EXAMPLE_TOKEN", "do-not-record-me")
+ got := safeSeniorDevEnvironment()
+ if got["SENIOR_DEV_NET"] != "off" {
+ t.Fatalf("ordinary variable missing: %#v", got)
+ }
+ if got["SENIOR_DEV_EXAMPLE_TOKEN"] != "" {
+ t.Fatalf("secret-like value was not redacted: %#v", got)
+ }
+}
+
+func TestPatchSummaryEmitsBoundedMachineReadableMetrics(t *testing.T) {
+ runner := gitTestRepo(t)
+ var output bytes.Buffer
+ runner.events = newEventWriter(&output)
+ writeWorkspace(t, runner, "main.go", "candidate\n")
+ runner.emitPatchSummary(gitOutput(context.Background(), runner.workspace, "rev-parse", "HEAD"))
+ for _, fragment := range []string{
+ `"stage":"patch-summary"`, `"status":"completed"`,
+ `"files":1`, `"additions":1`, `"deletions":1`, `"patch_bytes":`,
+ } {
+ if !strings.Contains(output.String(), fragment) {
+ t.Fatalf("patch summary missing %s: %s", fragment, output.String())
+ }
+ }
+}
diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go
new file mode 100644
index 0000000000..45afd5c9f9
--- /dev/null
+++ b/internal/seniordev/app/pipeline.go
@@ -0,0 +1,413 @@
+//go:build !windows
+
+// This file is the pipeline driver: budget, run base and workspace
+// preparation around the solo run in solo.go.
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf16"
+ "unicode/utf8"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/runbudget"
+ "github.com/Agent-Field/codeaf/internal/seniordev/util"
+)
+
+type pipelineDeps struct {
+ Backend backend
+ Config *seniorDevConfig
+ Events *eventWriter
+ Notes io.Writer
+ Now func() time.Time
+ Sleep func(context.Context, time.Duration) error
+}
+
+type pipeline struct {
+ args cliArgs
+ workspace string
+ sessionID string
+ runtime *runtimeAdapter
+ pool poolResolver
+ events *eventWriter
+ notes io.Writer
+ // recorder identifies, compares, freezes and restores the tree. Set in
+ // prepareWorkspace, once the workspace path is absolute.
+ recorder workspaceRecorder
+ now func() time.Time
+ sleep func(context.Context, time.Duration) error
+ wallStart time.Time
+ priorCost float64
+ budget runbudget.RunBudget
+ budgetRun *runbudget.BudgetTracker
+
+ budgetCost float64
+
+ fingerprintMu sync.Mutex
+ fingerprintFiles map[string]worktreeFileFingerprint
+ fingerprintNonce uint64
+
+ // verificationTimeouts remembers entrypoints that hung at the verification
+ // ceiling so a second pass does not pay the full ceiling again for an
+ // identical command against an unchanged tree.
+ verificationTimeouts map[string]timedOutEntrypoint
+ // verifyForTest overrides the project verification the finalizer runs.
+ // Nil in production; a seam for tests, which have no discoverable project
+ // entrypoints to verify.
+ verifyForTest func(context.Context) projectVerificationResult
+ // turnForTest overrides soloTurn. Nil in production; a seam for tests,
+ // which have no model to converse with.
+ turnForTest func(ctx context.Context, goal, prompt string) (turnResult, error)
+ // lastVerify remembers the most recent completed full verification and
+ // the git tree it measured, so the finalizer can judge an unchanged tree
+ // on the last verdict (rememberVerifiedTree in workspace_git.go).
+ lastVerify *projectVerificationResult
+ lastVerifyTreeSHA string
+}
+
+type pipelineResult struct {
+ Status string
+ Reason string
+ BaseSHA string
+ CostUSD float64
+ // Terminal is the solo run's own account of how it ended: whether it
+ // submitted, its stated reason, nudge count, the frozen tree, and what
+ // verification observed. It is emitted verbatim on the single terminal
+ // event; see persistTerminalResult.
+ Terminal map[string]any
+ WallStart time.Time
+}
+
+var errWallClockBudget = errors.New("wall-clock budget exhausted")
+
+// errRunBudget marks a mid-dispatch budget stop. Exhaustion is an ordinary
+// ending that exits 0, so this must not surface as a crash.
+var errRunBudget = errors.New("run budget exhausted")
+
+func newPipeline(args cliArgs, workspace string, deps pipelineDeps) *pipeline {
+ now := deps.Now
+ if now == nil {
+ now = time.Now
+ }
+ sleep := deps.Sleep
+ if sleep == nil {
+ sleep = func(ctx context.Context, duration time.Duration) error {
+ timer := time.NewTimer(duration)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+ }
+ }
+ pool := poolResolver{
+ high: splitPool(args.High), low: splitPool(args.Low),
+ frontier: splitPool(args.Frontier),
+ }
+ router := initRunRouter(args, deps.Events)
+ if aware, ok := deps.Backend.(adaptiveRouterBackend); ok {
+ aware.setAdaptiveRouter(router)
+ }
+ runtime := newConfiguredRuntime(workspace, deps.Backend, deps.Config)
+ runtime.now = now
+ runtime.events = deps.Events
+ if deps.Events != nil && runtime.bus != nil {
+ runtime.unsubscribeEvents = runtime.bus.SubscribeAllCallback(deps.Events.busEvent)
+ }
+ notes := deps.Notes
+ if notes == nil {
+ notes = io.Discard
+ }
+ return &pipeline{
+ args: args, workspace: workspace,
+ // Replaced in prepareWorkspace once the path is absolute. Set here so
+ // a pipeline is never half-built: every method that touches the tree
+ // has a recorder to ask.
+ recorder: newWorkspaceRecorder(args, workspace, func(message string) {
+ _, _ = io.WriteString(notes, message)
+ }),
+ sessionID: runtime.nextID("session"), runtime: runtime, pool: pool,
+ events: deps.Events, notes: notes,
+ now: now, sleep: sleep, wallStart: now(),
+ budget: runbudget.ResolveRunBudget(&runbudget.RunBudgetFlags{
+ MaxCost: args.MaxCost, MaxHours: args.MaxHours,
+ }, nil),
+ }
+}
+
+func (runner *pipeline) run(
+ ctx context.Context, goal string,
+) (result pipelineResult, runErr error) {
+ result = runner.initializeRun()
+ if runner.budget.MaxWallMS != nil {
+ limit := time.Duration(*runner.budget.MaxWallMS * float64(time.Millisecond))
+ deadline := runner.wallStart.Add(limit)
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithDeadlineCause(ctx, deadline, errWallClockBudget)
+ defer cancel()
+ defer func() {
+ if !errors.Is(context.Cause(ctx), errWallClockBudget) || result.Status == "pass" {
+ return
+ }
+ result.Status = "budget-exhausted"
+ _, result.Reason = runner.budgetExhausted()
+ if result.Reason == "" {
+ result.Reason = errWallClockBudget.Error()
+ }
+ result.CostUSD = runner.totalCost()
+ runErr = nil
+ }()
+ }
+ baseSHA, done, err := runner.prepareRunBase(ctx, &result)
+ if err != nil {
+ return result, err
+ }
+ if done {
+ return result, nil
+ }
+ contract := map[string]any{
+ "base_sha": baseSHA,
+ "high_models": runner.pool.values(baked.TierHigh),
+ "low_models": runner.pool.values(baked.TierLow),
+ "frontier_models": runner.pool.values(baked.TierFrontier),
+ "entry_agent": "coder",
+ "senior_dev_environment": safeSeniorDevEnvironment(),
+ // Which promises the run is keeping about the tree, and how. A reader
+ // comparing two runs needs this before it compares anything else.
+ "workspace_recorder": runner.recorder.Kind(),
+ }
+ runner.events.stage("run-contract", "ready", contract)
+ defer runner.emitPatchSummary(baseSHA)
+ if err := runner.runtime.ensureRootSession(
+ ctx, runner.sessionID, prefixUTF16(goal, 60), "coder",
+ ); err != nil {
+ return result, err
+ }
+ outcome, err := runner.runSolo(ctx, goal, baseSHA)
+ result.CostUSD = runner.totalCost()
+ // Captured before the error check: ship now runs on every ending, so even a
+ // wall-clock kill leaves the run's own account of what it did, and that
+ // account is the terminal event's payload.
+ result.Terminal = outcome.TerminalData
+ if err != nil {
+ return result, err
+ }
+ result.Status, result.Reason = soloResultStatus(outcome)
+ if exhausted, reason := runner.budgetExhausted(); exhausted && result.Status != "pass" {
+ result.Status, result.Reason = "budget-exhausted", reason
+ }
+ result.CostUSD = runner.totalCost()
+ return result, nil
+}
+
+// soloResultStatus projects the run's own vocabulary onto the pass/fail
+// statuses of the terminal event. The distinctions the solo pipeline draws --
+// unverified because an entrypoint hung, unsubmitted because the model never
+// declared done -- are not lost: they are the reason string, and the terminal
+// event carries them structurally.
+func soloResultStatus(outcome soloOutcome) (string, string) {
+ reason := outcome.SubmissionReason
+ switch outcome.Status {
+ case "pass":
+ return "pass", reason
+ case "pass-unverified":
+ return "pass", "submitted; verification did not complete"
+ case "unsubmitted":
+ return "fail", "the run ended without submitting"
+ default:
+ if reason == "" {
+ reason = "the submitted candidate did not verify"
+ }
+ return "fail", reason
+ }
+}
+
+// resolveRunBase is the commit every patch in this run is measured against.
+// A run starts from wherever HEAD is: there is no inherited base, because
+// there is no second process that could have moved the tree first.
+func (runner *pipeline) resolveRunBase(ctx context.Context) (string, error) {
+ return runner.recorder.Base(ctx)
+}
+
+func safeSeniorDevEnvironment() map[string]string {
+ result := map[string]string{}
+ for _, entry := range os.Environ() {
+ name, value, ok := strings.Cut(entry, "=")
+ if !ok || !strings.HasPrefix(name, "SENIOR_DEV_") {
+ continue
+ }
+ upper := strings.ToUpper(name)
+ if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") ||
+ strings.Contains(upper, "SECRET") || strings.Contains(upper, "PASSWORD") {
+ result[name] = ""
+ continue
+ }
+ result[name] = value
+ }
+ return result
+}
+
+func (runner *pipeline) prepareWorkspace(ctx context.Context) error {
+ absolute, err := filepath.Abs(runner.workspace)
+ if err != nil {
+ return err
+ }
+ runner.workspace = absolute
+ if info, err := os.Stat(absolute); err != nil || !info.IsDir() {
+ return fmt.Errorf("workspace is not a directory: %s", absolute)
+ }
+ runner.recorder = newWorkspaceRecorder(runner.args, absolute, runner.note)
+ if err := runner.recorder.Prepare(ctx); err != nil {
+ return err
+ }
+ if !runner.recorder.CommitsOnWrite() {
+ // The recorder keeps its own copies of the tree, so a per-write commit
+ // buys nothing -- and under --in-place the workspace may be a
+ // repository this run has no business writing history into.
+ util.DisableEagerCommit()
+ }
+ runner.events.stage("bootstrap", "ready", map[string]any{
+ "workspace": absolute, "recorder": runner.recorder.Kind(),
+ })
+ return nil
+}
+
+func (runner *pipeline) note(message string) {
+ _, _ = io.WriteString(runner.notes, message)
+}
+
+// worktreeFingerprint hashes the content and modes of every tracked or
+// unignored file. Unlike `git status --porcelain`, it detects a formatter
+// changing the bytes of an already-modified file; unlike HEAD+diff, it does not
+// mistake a history-only rewrite with an identical checked-out tree for a
+// source mutation. File count, bytes, and wall time are bounded. Metadata lets
+// unchanged files reuse their prior content hash; only new or metadata-changed
+// files are read again.
+func (runner *pipeline) worktreeFingerprint(ctx context.Context) (string, bool) {
+ runner.fingerprintMu.Lock()
+ defer runner.fingerprintMu.Unlock()
+ return newWorktreeFingerprinter(runner, ctx).fingerprint()
+}
+
+func (runner *pipeline) overBudgetFingerprint() string {
+ runner.fingerprintNonce++
+ return fmt.Sprintf("changed:worktree-fingerprint-budget:%d", runner.fingerprintNonce)
+}
+
+func (runner *pipeline) totalCost() float64 {
+ runner.ensureBudgetTracker()
+ runtimeCost := runner.runtime.cost()
+ if delta := runtimeCost - runner.budgetCost; delta > 0 {
+ runner.budgetRun.AddCost(delta)
+ }
+ runner.budgetCost = runtimeCost
+ return runner.budgetRun.CostUSD()
+}
+
+func (runner *pipeline) budgetExhausted() (bool, string) {
+ runner.totalCost()
+ exhausted := runner.budgetRun.Exhausted(float64(runner.now().UnixMilli()))
+ if exhausted.Yes {
+ reason := "run budget exhausted"
+ if exhausted.Reason != nil {
+ reason = *exhausted.Reason
+ }
+ return true, reason
+ }
+ return false, ""
+}
+
+func (runner *pipeline) ensureBudgetTracker() {
+ if runner.budgetRun != nil {
+ return
+ }
+ if !runbudget.IsBounded(runner.budget) {
+ runner.budget = runbudget.ResolveRunBudget(&runbudget.RunBudgetFlags{
+ MaxCost: runner.args.MaxCost, MaxHours: runner.args.MaxHours,
+ }, nil)
+ }
+ runner.budgetRun = runbudget.MakeBudgetTracker(
+ runner.budget, float64(runner.wallStart.UnixMilli()), runner.priorCost,
+ )
+}
+
+func firstModel(models []string) string {
+ if len(models) == 0 {
+ return ""
+ }
+ return models[0]
+}
+
+// splitModelID splits a "provider/model" reference on its first slash. A
+// reference without a slash is all provider and no model.
+func splitModelID(value string) (providerID, modelID string) {
+ providerID, modelID, _ = strings.Cut(value, "/")
+ return providerID, modelID
+}
+
+func gitOutput(ctx context.Context, workspace string, args ...string) string {
+ command := exec.CommandContext(ctx, "git", args...)
+ command.Dir = workspace
+ output, err := command.Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(output))
+}
+
+func truncate(value string, limit int) string {
+ runes := []rune(value)
+ if len(runes) <= limit {
+ return value
+ }
+ return string(runes[:limit])
+}
+
+// prefixUTF16 truncates value to limit UTF-16 code units.
+func prefixUTF16(value string, limit int) string {
+ units := utf16.Encode([]rune(value))
+ if len(units) <= limit {
+ return value
+ }
+ units = units[:limit]
+ out := make([]byte, 0, len(value))
+ for index := 0; index < len(units); index++ {
+ unit := units[index]
+ if unit >= 0xd800 && unit <= 0xdbff &&
+ index+1 < len(units) &&
+ units[index+1] >= 0xdc00 && units[index+1] <= 0xdfff {
+ out = utf8.AppendRune(out, utf16.DecodeRune(rune(unit), rune(units[index+1])))
+ index++
+ continue
+ }
+ if unit >= 0xd800 && unit <= 0xdfff {
+ out = append(out,
+ byte(0xe0|unit>>12),
+ byte(0x80|(unit>>6)&0x3f),
+ byte(0x80|unit&0x3f),
+ )
+ continue
+ }
+ out = utf8.AppendRune(out, rune(unit))
+ }
+ return string(out)
+}
+
+func suffixUTF16(value string, limit int) string {
+ units := utf16.Encode([]rune(value))
+ if len(units) <= limit {
+ return value
+ }
+ return string(utf16.Decode(units[len(units)-limit:]))
+}
diff --git a/internal/seniordev/app/pipeline_run.go b/internal/seniordev/app/pipeline_run.go
new file mode 100644
index 0000000000..e26b8299ad
--- /dev/null
+++ b/internal/seniordev/app/pipeline_run.go
@@ -0,0 +1,62 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/runbudget"
+)
+
+// initializeRun sets the run's starting verdict and budget. The verdict starts
+// at "crashed" so a process that dies before its terminal event is reported as
+// having died, not as having quietly produced nothing.
+func (runner *pipeline) initializeRun() pipelineResult {
+ result := pipelineResult{Status: "crashed", WallStart: runner.wallStart}
+ runner.budgetRun = runbudget.MakeBudgetTracker(
+ runner.budget, float64(runner.wallStart.UnixMilli()), runner.priorCost,
+ )
+ runner.budgetCost = 0
+ runner.noteRunBudget()
+ return result
+}
+
+func (runner *pipeline) noteRunBudget() {
+ if !runbudget.IsBounded(runner.budget) {
+ return
+ }
+ cost := "cost=unbounded"
+ if runner.budget.MaxCostUSD != nil {
+ cost = fmt.Sprintf("maxCost=$%v", *runner.budget.MaxCostUSD)
+ }
+ wall := "wall=unbounded"
+ if runner.budget.MaxWallMS != nil {
+ wall = fmt.Sprintf("maxWall=%vh", math.Round(*runner.budget.MaxWallMS/36_000)/100)
+ }
+ restored := ""
+ if runner.priorCost > 0 {
+ restored = fmt.Sprintf(" (restored: $%.4f already spent)", runner.priorCost)
+ }
+ runner.note("[senior-dev] run budget: " + cost + " " + wall + restored + "\n")
+}
+
+func (runner *pipeline) prepareRunBase(
+ ctx context.Context, result *pipelineResult,
+) (string, bool, error) {
+ if err := runner.prepareWorkspace(ctx); err != nil {
+ return "", false, err
+ }
+ if exhausted, reason := runner.budgetExhausted(); exhausted {
+ result.Status, result.Reason = "budget-exhausted", reason
+ result.CostUSD = runner.totalCost()
+ return "", true, nil
+ }
+ baseSHA, err := runner.resolveRunBase(ctx)
+ if err != nil {
+ return "", false, err
+ }
+ result.BaseSHA = baseSHA
+ return baseSHA, false, nil
+}
diff --git a/internal/seniordev/app/pipeline_smoke_test.go b/internal/seniordev/app/pipeline_smoke_test.go
new file mode 100644
index 0000000000..fdd51e4513
--- /dev/null
+++ b/internal/seniordev/app/pipeline_smoke_test.go
@@ -0,0 +1,336 @@
+//go:build !windows
+
+// This file exercises the solo run end to end against a scripted backend.
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+)
+
+func eventStages(t *testing.T, raw []byte) []string {
+ t.Helper()
+ lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n"))
+ out := []string{}
+ for _, line := range lines {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil {
+ t.Fatalf("invalid NDJSON event %q: %v", line, err)
+ }
+ if value.Stage != "" {
+ out = append(out, value.Stage)
+ }
+ }
+ return out
+}
+
+func assertOrderedStages(t *testing.T, got, want []string) {
+ t.Helper()
+ at := 0
+ for _, stage := range got {
+ if at < len(want) && stage == want[at] {
+ at++
+ }
+ }
+ if at != len(want) {
+ t.Fatalf("stage order = %v, missing ordered suffix %v", got, want[at:])
+ }
+}
+
+func writeFile(path, content string) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(path, []byte(content), 0o644)
+}
+
+func gitRun(directory string, args ...string) error {
+ command := exec.Command("git", args...)
+ command.Dir = directory
+ command.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=senior-dev-smoke",
+ "GIT_AUTHOR_EMAIL=senior-dev@example.test",
+ "GIT_COMMITTER_NAME=senior-dev-smoke",
+ "GIT_COMMITTER_EMAIL=senior-dev@example.test",
+ )
+ output, err := command.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, output)
+ }
+ return nil
+}
+
+// soloScriptedBackend drives one full solo run offline: the coder explores,
+// writes a file, pins a command, and submits.
+type soloScriptedBackend struct {
+ calls int
+ // onTurn runs before the scripted tool calls, so a test can make the model
+ // misbehave -- stop without submitting, submit twice, edit after freezing.
+ onTurn func(call int, request turn) (turnResult, bool, error)
+}
+
+func (backend *soloScriptedBackend) Run(
+ ctx context.Context, request turn,
+) (turnResult, error) {
+ backend.calls++
+ if backend.onTurn != nil {
+ if result, handled, err := backend.onTurn(backend.calls, request); handled {
+ return result, err
+ }
+ }
+ if request.Execute == nil {
+ return turnResult{Text: "no tools available"}, nil
+ }
+ call := func(name, input string) (steploop.ToolResult, error) {
+ return request.Execute(ctx, steploop.ToolCall{
+ ID: fmt.Sprintf("call_%d", backend.calls), Name: name,
+ Input: json.RawMessage(input), SessionID: request.SessionID, Agent: request.Agent,
+ })
+ }
+ if _, err := call("write", `{"filePath":"feature.txt","content":"implemented\n"}`); err != nil {
+ return turnResult{}, err
+ }
+ // The real protocol writes a checklist in stage 0 and submit refuses without
+ // one, so a backend that models the run has to write one too.
+ if _, err := call("write", `{"filePath":".senior-dev/checklist.md","content":"- [x] feature implemented\n"}`); err != nil {
+ return turnResult{}, err
+ }
+ if _, err := call("write", `{"filePath":".senior-dev/pinned.txt","content":"make test\n"}`); err != nil {
+ return turnResult{}, err
+ }
+ result, err := call("submit", `{"reason":"feature implemented",`+
+ `"evidence":"make test exit 0","checklist_satisfied":true}`)
+ if err != nil {
+ return turnResult{}, err
+ }
+ return turnResult{Text: "done: " + result.Output}, nil
+}
+
+func TestSoloRunGoesIntakeToFrozenShipInOneContext(t *testing.T) {
+ // The end-to-end shape, offline. What it proves is the sequence and the
+ // session count: one coding context, one submission, one terminal.
+ //
+ // The Makefile is part of the COMMITTED base: it is the project's existing
+ // build system, not something this run produced. Leaving it uncommitted
+ // would make it part of the candidate and the file count would not measure
+ // what the run actually contributed.
+ workspace := gitWorkspace(t, map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ })
+ base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD"))
+ var events bytes.Buffer
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Backend: &soloScriptedBackend{}, Events: newEventWriter(&events), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ outcome, err := runner.runSolo(context.Background(), "Add the feature.", base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if outcome.Status != "pass" {
+ t.Fatalf("status = %q (%#v)", outcome.Status, outcome)
+ }
+ if outcome.Nudges != 0 {
+ t.Fatalf("a run that submitted on its first turn was nudged %d time(s)", outcome.Nudges)
+ }
+ if outcome.Frozen == nil || outcome.Frozen.Reason != "feature implemented" {
+ t.Fatalf("frozen candidate = %#v", outcome.Frozen)
+ }
+
+ // No "terminal" stage: the terminal is a type=="terminal" event emitted by
+ // the CLI layer, which runSolo is below. What runSolo must produce is the
+ // payload for it.
+ assertOrderedStages(t, eventStages(t, events.Bytes()),
+ []string{"intake", "implement", "submit", "implement", "verification", "ship"})
+ if outcome.TerminalData["submitted"] != true {
+ t.Fatalf("terminal payload = %#v", outcome.TerminalData)
+ }
+
+ // The submitted file is what is on disk, and senior-dev's own bookkeeping did
+ // not become the deliverable.
+ content, err := os.ReadFile(filepath.Join(workspace, "feature.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != "implemented\n" {
+ t.Fatalf("shipped file = %q", content)
+ }
+ if outcome.Frozen.PatchFiles != 1 {
+ t.Fatalf("PatchFiles = %d, want 1", outcome.Frozen.PatchFiles)
+ }
+}
+
+func TestSoloRunNudgesThenGivesUpHonestly(t *testing.T) {
+ // A model that never submits must not produce a run that reports an
+ // attempt. It gets soloMaxNudges chances carrying the facts senior-dev checked,
+ // and then the terminal says plainly that nothing was submitted.
+ workspace, base := guardWorkspace(t)
+ backend := &soloScriptedBackend{
+ onTurn: func(int, turn) (turnResult, bool, error) {
+ return turnResult{Text: "I believe this is complete."}, true, nil
+ },
+ }
+ var events bytes.Buffer
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Backend: backend, Events: newEventWriter(&events), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ outcome, err := runner.runSolo(context.Background(), "Add the feature.", base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if outcome.Status != "unsubmitted" {
+ t.Fatalf("status = %q, want unsubmitted", outcome.Status)
+ }
+ if backend.calls != soloMaxNudges+1 {
+ t.Fatalf("model turns = %d, want %d (one attempt plus %d nudges)",
+ backend.calls, soloMaxNudges+1, soloMaxNudges)
+ }
+ if status, reason := soloResultStatus(outcome); status != "fail" ||
+ !strings.Contains(reason, "without submitting") {
+ t.Fatalf("result status = %q / %q", status, reason)
+ }
+}
+
+func TestSoloRunCorrectsPlainTextDSMLWithoutSpendingANudge(t *testing.T) {
+ workspace := gitWorkspace(t, map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ })
+ base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD"))
+ backend := &soloScriptedBackend{
+ onTurn: func(call int, _ turn) (turnResult, bool, error) {
+ if call == 1 {
+ return turnResult{Text: `<|DSML|bash>{"cmd":"make test"}`}, true, nil
+ }
+ return turnResult{}, false, nil
+ },
+ }
+ var events bytes.Buffer
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Backend: backend, Events: newEventWriter(&events), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ outcome, err := runner.runSolo(context.Background(), "Add the feature.", base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if outcome.Status != "pass" || outcome.Nudges != 0 || backend.calls != 2 {
+ t.Fatalf("outcome=%#v calls=%d", outcome, backend.calls)
+ }
+ leaks := soloStageEvents(t, &events, "implement")
+ found := false
+ for _, event := range leaks {
+ found = found || event["status"] == "tool-call-leak"
+ }
+ if !found {
+ t.Fatal("plain-text tool call was not recorded")
+ }
+}
+
+func TestToolLeakDetectorDoesNotOverrideAnExecutedToolCall(t *testing.T) {
+ result := turnResult{
+ Text: "DSML bash markup appeared in an explanation",
+ Parts: []turnPart{{Type: "tool", Tool: "bash", Status: "completed"}},
+ }
+ if leakedToolCall(result) {
+ t.Fatal("an executed tool call was misclassified as leaked markup")
+ }
+}
+
+// TestBudgetExhaustedRunStillShipsAndReportsWhy pins ship running on every
+// ending. If soloConverse returning an error sent runSolo home before
+// soloShip, the common ending of a full-budget run would produce neither a
+// restore nor any statement of whether the run had submitted: both halves of
+// stage 4 skipped on the ending that happens most.
+func TestBudgetExhaustedRunStillShipsAndReportsWhy(t *testing.T) {
+ workspace, base := guardWorkspace(t)
+ backend := &soloScriptedBackend{
+ onTurn: func(int, turn) (turnResult, bool, error) {
+ return turnResult{}, true, context.DeadlineExceeded
+ },
+ }
+ var events bytes.Buffer
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Backend: backend, Events: newEventWriter(&events), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ outcome, err := runner.runSolo(context.Background(), "Add the feature.", base)
+ if err == nil {
+ t.Fatal("a turn that died on its deadline returned no error")
+ }
+ // The error propagates -- the run did die -- but the account survives it.
+ if outcome.Status != "unsubmitted" {
+ t.Fatalf("status = %q, want unsubmitted", outcome.Status)
+ }
+ if outcome.TerminalData == nil {
+ t.Fatal("a run killed mid-turn produced no terminal payload")
+ }
+ if outcome.TerminalData["submitted"] != false {
+ t.Fatalf("terminal payload = %#v", outcome.TerminalData)
+ }
+ reason, _ := outcome.TerminalData["reason"].(string)
+ if !strings.Contains(reason, "without calling submit") {
+ t.Fatalf("terminal reason = %q, want it to name the missing submission", reason)
+ }
+ // The cause is carried too, so a reader can tell "never tried" from "ran
+ // out of time trying".
+ if !strings.Contains(reason, context.DeadlineExceeded.Error()) {
+ t.Fatalf("terminal reason = %q, want it to carry the underlying cause", reason)
+ }
+}
+
+// TestTerminalEventCarriesTheRunsAccount pins the contract at the boundary an
+// external reader sees: the type=="terminal" record -- not a stage named
+// "terminal" -- has to answer whether the run submitted. A test that asserts
+// on the stage event alone passes while the terminal carries only a cost.
+func TestTerminalEventCarriesTheRunsAccount(t *testing.T) {
+ result := pipelineResult{
+ Status: "fail", Reason: "the run ended without submitting",
+ CostUSD: 0.25,
+ Terminal: map[string]any{"submitted": false, "nudges": 2, "reason": "no submission"},
+ }
+ var out bytes.Buffer
+ if err := delegate.NewEmitter(&out).Terminal(endingOf(result)); err != nil {
+ t.Fatal(err)
+ }
+
+ var terminals []map[string]any
+ for _, line := range bytes.Split(out.Bytes(), []byte("\n")) {
+ if len(bytes.TrimSpace(line)) == 0 {
+ continue
+ }
+ var value map[string]any
+ if err := json.Unmarshal(line, &value); err != nil {
+ continue
+ }
+ if value["type"] == "terminal" {
+ terminals = append(terminals, value)
+ }
+ }
+ if len(terminals) != 1 {
+ t.Fatalf("type==terminal events = %d, want exactly 1", len(terminals))
+ }
+ data, _ := terminals[0]["data"].(map[string]any)
+ for _, key := range []string{"submitted", "nudges", "reason", "cost_usd"} {
+ if _, ok := data[key]; !ok {
+ t.Fatalf("counted terminal event is missing %q: %#v", key, data)
+ }
+ }
+}
diff --git a/internal/seniordev/app/prompt_in_place.go b/internal/seniordev/app/prompt_in_place.go
new file mode 100644
index 0000000000..7de31f6e49
--- /dev/null
+++ b/internal/seniordev/app/prompt_in_place.go
@@ -0,0 +1,108 @@
+//go:build !windows
+
+package app
+
+import (
+ "fmt"
+ "strings"
+)
+
+// The prompts the model reads name git in a few places, because under the
+// default recorder git is how the promises are kept. Under --in-place they are
+// not, and a prompt that says otherwise is a prompt that lies: the model would
+// reach for `git diff` to review its own work and get an error back.
+//
+// The git-mode text is NOT edited. Every rewrite below is applied only on the
+// in-place path, so a default run's prompt bytes — and therefore its prompt
+// hash, its cache prefix and its comparability to earlier runs — are exactly
+// what they were before this mode existed. That is the whole reason this is a
+// substitution table rather than a reworded prompt.
+//
+// Each entry must fire. A rewrite that silently matches nothing would leave
+// the model with git-shaped instructions it cannot follow, so applyInPlace
+// returns an error naming the miss, and a test pins every entry against the
+// real prompt text.
+type promptRewrite struct {
+ from string
+ to string
+}
+
+// coderPromptRewrites adapt the baked system prompt.
+var coderPromptRewrites = []promptRewrite{
+ {
+ from: "from the starting commit, when `.senior-dev/checklist.md` does not exist, when",
+ to: "from the tree senior-dev recorded at the start, when `.senior-dev/checklist.md` does not exist, when",
+ },
+ {
+ from: "`.senior-dev/` and git-ignored paths are excluded from the answer. Everything else",
+ to: "`.senior-dev/` and ignored paths are excluded from the answer. Everything else",
+ },
+}
+
+// soloPromptRewrites adapt the run instruction, and add the one thing the
+// model cannot infer: that git is not available to it here.
+var soloPromptRewrites = []promptRewrite{
+ {
+ from: "The workspace is a git repository. Your tools are the ones declared with this\nturn: a shell, file reading, editing, search, web access, and submit.",
+ to: "The workspace is a directory. It may or may not be a git repository, and " +
+ "either way\nthis run does not use git: it makes no commits and creates no " +
+ "branches, and\n`git diff` will not show you your work. What is on disk is " +
+ "the record.\n\nYour tools are the ones declared with this turn: a shell, " +
+ "file reading, editing,\nsearch, web access, and submit.",
+ },
+ {
+ from: ".senior-dev/ and git-ignored paths are excluded from the answer. Everything else in\nthe working tree, committed or not, is part of what you submit.",
+ to: ".senior-dev/ and ignored paths are excluded from the answer. Everything else in\nthe working tree is part of what you submit.",
+ },
+ {
+ from: "It refuses, naming the cause, when the tree is unchanged from the starting\ncommit, when .senior-dev/checklist.md does not exist, when reason or evidence is\nempty, or when this run already submitted. A refusal does not end the run.",
+ to: "It refuses, naming the cause, when the tree is unchanged from the one senior-dev\nrecorded at the start, when .senior-dev/checklist.md does not exist, when reason or\nevidence is empty, or when this run already submitted. A refusal does not end\nthe run.",
+ },
+}
+
+// applyPromptRewrites returns text with every rewrite applied, or an error
+// naming the first one that matched nothing.
+func applyPromptRewrites(text string, rewrites []promptRewrite) (string, error) {
+ for index, rewrite := range rewrites {
+ if !strings.Contains(text, rewrite.from) {
+ return "", fmt.Errorf(
+ "in-place prompt rewrite %d no longer matches the prompt: %q",
+ index, firstLine(rewrite.from),
+ )
+ }
+ text = strings.Replace(text, rewrite.from, rewrite.to, 1)
+ }
+ return text, nil
+}
+
+func firstLine(value string) string {
+ if index := strings.IndexByte(value, '\n'); index >= 0 {
+ return value[:index]
+ }
+ return value
+}
+
+// rewritesGitText reports whether this recorder's prompts need adapting. Only
+// the git recorder leaves them alone.
+func rewritesGitText(recorder workspaceRecorder) bool {
+ return recorder != nil && recorder.Kind() != "git"
+}
+
+// adaptCoderPrompt rewrites the baked system prompt. It runs on every turn,
+// because the system prompt is rebuilt for each one.
+func adaptCoderPrompt(recorder workspaceRecorder, coder string) (string, error) {
+ if !rewritesGitText(recorder) {
+ return coder, nil
+ }
+ return applyPromptRewrites(coder, coderPromptRewrites)
+}
+
+// adaptSoloPrompt rewrites the run instruction. It runs once, where that
+// instruction is assembled -- later turns carry short continuations that never
+// contained this text and must not be searched for it.
+func adaptSoloPrompt(recorder workspaceRecorder, solo string) (string, error) {
+ if !rewritesGitText(recorder) {
+ return solo, nil
+ }
+ return applyPromptRewrites(solo, soloPromptRewrites)
+}
diff --git a/internal/seniordev/app/question_autoreject_test.go b/internal/seniordev/app/question_autoreject_test.go
new file mode 100644
index 0000000000..8be5704062
--- /dev/null
+++ b/internal/seniordev/app/question_autoreject_test.go
@@ -0,0 +1,58 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/question"
+)
+
+// A headless run has nothing that answers question.asked, so an unanswered
+// question would block the run forever. The senior-dev runtime auto-rejects
+// through the service's own reject path, so the call returns promptly with
+// the rejection instead of hanging the run.
+func TestHeadlessQuestionAutoRejectsInsteadOfHanging(t *testing.T) {
+ runtime := newRuntime(t.TempDir(), nil)
+ t.Cleanup(runtime.Close)
+
+ input, err := json.Marshal(map[string]any{
+ "questions": []map[string]any{{
+ "question": "Which storage backend should the service use?",
+ "header": "Storage",
+ "options": []map[string]any{
+ {"label": "sqlite", "description": "Embedded file database"},
+ {"label": "postgres", "description": "Networked relational database"},
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ type outcome struct {
+ result steploop.ToolResult
+ err error
+ }
+ done := make(chan outcome, 1)
+ go func() {
+ result, execErr := runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ Name: "question", Input: input,
+ ID: "call-q1", SessionID: "ses-headless", MessageID: "msg-q1", Agent: "coder",
+ })
+ done <- outcome{result: result, err: execErr}
+ }()
+ select {
+ case got := <-done:
+ var rejected *question.RejectedError
+ if !errors.As(got.err, &rejected) {
+ t.Fatalf("question returned (%#v, %v), want the rejection error", got.result, got.err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("question tool call hung: headless auto-reject did not fire")
+ }
+}
diff --git a/internal/seniordev/app/router_cancellation_test.go b/internal/seniordev/app/router_cancellation_test.go
new file mode 100644
index 0000000000..e317a1abdc
--- /dev/null
+++ b/internal/seniordev/app/router_cancellation_test.go
@@ -0,0 +1,79 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+)
+
+func TestRouterCancellationDecisionsAreTraced(t *testing.T) {
+ var output bytes.Buffer
+ router := initRunRouter(cliArgs{High: "openrouter/moonshotai/kimi-k3"}, newEventWriter(&output))
+ choice, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.RegisterCanceled(choice)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err = router.PickContext(ctx, "coder", adaptive.ModelTierHigh); err != context.Canceled {
+ t.Fatalf("err=%v", err)
+ }
+ lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n"))
+ if len(lines) != 2 {
+ t.Fatalf("trace=%s", output.Bytes())
+ }
+ for i, reason := range []string{"caller-canceled-request", "caller-canceled-pick"} {
+ var event event
+ if err = json.Unmarshal(lines[i], &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Stage != "router-cancellation" || event.Status != reason || event.Data["provider_health_changed"] != false {
+ t.Fatalf("event=%+v", event)
+ }
+ }
+}
+
+// THE CODER MOVING TO ANOTHER MODEL IS A STAGE, with where it came from, where
+// it went and the router's reason; a pick that stays on its model, and the
+// run's first pick, move nothing and say nothing.
+func TestTheCodersMoveToAnotherModelIsAStage(t *testing.T) {
+ var output bytes.Buffer
+ router := initRunRouter(cliArgs{High: "openrouter/vendor/one,openrouter/vendor/two"}, newEventWriter(&output))
+ first, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Register(first, 1, 10, errors.New("429 rate limit exceeded"))
+ second, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !second.Switched {
+ t.Fatalf("the router stayed on %s after a rate limit; the test cannot see a switch", second.Candidate.ID)
+ }
+ router.Register(second, 1, 10, nil)
+ var switches []event
+ for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil {
+ t.Fatal(err)
+ }
+ if value.Stage == "model-switch" {
+ switches = append(switches, value)
+ }
+ }
+ if len(switches) != 1 {
+ t.Fatalf("model-switch stages = %d, want the one real change: %s", len(switches), output.Bytes())
+ }
+ got := switches[0]
+ if got.Status != "switched" || got.Data["from"] != first.Candidate.ID || got.Data["to"] != second.Candidate.ID || got.Data["reason"] == "" {
+ t.Fatalf("the switch = %+v, want from %s to %s with a reason", got, first.Candidate.ID, second.Candidate.ID)
+ }
+}
diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go
new file mode 100644
index 0000000000..e438fc53f6
--- /dev/null
+++ b/internal/seniordev/app/run.go
@@ -0,0 +1,382 @@
+//go:build !windows
+
+// This file is one run as codeaf starts it: what senior-dev's own command line
+// used to do between parsing its flags and printing its terminal event, with
+// codeaf's host in place of stdout and codeaf's model API in place of a key.
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/buildinfo"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev"
+ "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy"
+)
+
+// version is the build senior-dev reports itself as: to the session store it
+// writes and to the model catalog it fetches. It is codeaf's own, because
+// there is no senior-dev that differs from the codeaf it ships in.
+var version = buildinfo.String()
+
+// Stages are the stages a run reports, in the order a run first reaches them:
+// the `hello` codeaf draws the whole track from before the run has walked it.
+//
+// THE LIST IS CLOSED, and a test holds it to the source: every stage the run
+// can emit is here, and nothing is here that it cannot emit
+// (stages_test.go). compaction-capacity, compaction, router-cancellation and
+// model-switch happen inside a model turn — when a rejection pins a window, the
+// history is compacted, a call is withdrawn or the coder moves to another
+// model — so they sit where the turns are.
+var Stages = []string{
+ "bootstrap",
+ "run-contract",
+ "intake",
+ "landing",
+ "implement",
+ "agent-runtime",
+ "compaction-capacity",
+ "compaction",
+ "router-cancellation",
+ "model-switch",
+ "submit",
+ "verification",
+ "ship",
+ "patch-summary",
+ "agent-summary",
+}
+
+// Options is what one run is asked to do.
+type Options struct {
+ // Goal is the brief, exactly as it was given. It is written to
+ // .senior-dev/spec.md byte for byte and read back from there, so nothing
+ // between the person and the model paraphrases it.
+ Goal string
+ // High, Low and Frontier are the model pools, comma-separated; an empty Low
+ // or Frontier routes on High.
+ High string
+ Low string
+ Frontier string
+ // Variant is the reasoning effort, sent as `reasoning.effort`.
+ Variant string
+ // InPlace edits the folder without git: no commits, no refs, and the run's
+ // checkpoints kept outside it.
+ InPlace bool
+ // Crew says the pools came from the crew of the conversation that started
+ // the run (`--crew`), not from a person typing them: a model the catalog
+ // cannot size is dropped with a note, and a --high left empty routes on
+ // [DefaultHighModels] ([crewPools]).
+ Crew bool
+ // Asked says the --high pool is the models the person asked for (`--asked`).
+ // It is kept whole under Crew: every one of them must be sizable, and the
+ // run refuses before its first call, naming the one that is not
+ // ([askedRefusal]).
+ Asked bool
+}
+
+// Run runs senior-dev once in the host's workspace and answers how it ended.
+// It reports its stages and finished steps through host as it goes; the
+// hello before it and the terminal after it are the caller's, which is the
+// one place every ending reaches, a panic's included. notes is where its lines
+// for a person go: stderr, which codeaf keeps beside the task.
+func Run(ctx context.Context, host delegate.Host, options Options, notes io.Writer) delegate.Ending {
+ return runWith(ctx, host, options, notes, nil)
+}
+
+// runWith is Run with a model backend a test can put in the model API's
+// place. An injected backend is the whole of the model side: the run then
+// neither needs the host's model API nor loads the model catalog, the shape
+// senior-dev's own in-process tests always ran in.
+func runWith(ctx context.Context, host delegate.Host, options Options, notes io.Writer, injected backend) delegate.Ending {
+ if notes == nil {
+ notes = io.Discard
+ }
+ // A network policy that failed to parse refuses the run: an unrecognized
+ // SENIOR_DEV_NET must neither silently allow egress nor silently run a paid
+ // multi-hour job in a mode nobody asked for.
+ policy := netpolicy.Current()
+ if policy.Warning != "" {
+ return refused(policy.Warning)
+ }
+ if policy.Restricted() {
+ _, _ = io.WriteString(notes, "[senior-dev] network policy: off — agent-initiated egress disabled "+
+ "(the model API is unaffected)\n")
+ }
+ if strings.TrimSpace(options.Goal) == "" {
+ return refused("there is no brief: senior-dev needs the change to make, in words, after the flags")
+ }
+ api := host.Models()
+ if injected == nil && !api.Ready() {
+ return refused("senior-dev was started without a model API; codeaf serves one to every run it starts")
+ }
+ args := cliArgs{
+ High: options.High, Low: options.Low, Frontier: options.Frontier,
+ Variant: options.Variant, InPlace: options.InPlace,
+ }
+ if len(splitPool(args.High)) == 0 {
+ return refused("--high names no model, and the coder needs one to route on")
+ }
+ // A CEILING OF ZERO IS NO CEILING, and is passed as none, so senior-dev's
+ // own SENIOR_DEV_MAX_COST_USD and SENIOR_DEV_MAX_WALL_H still apply to a run
+ // codeaf set no limit on.
+ ceilings := host.Ceilings()
+ if ceilings.CostUSD > 0 {
+ args.MaxCost = &ceilings.CostUSD
+ }
+ if ceilings.Hours > 0 {
+ args.MaxHours = &ceilings.Hours
+ }
+ workspace := host.Workspace()
+ loadedConfig, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ return refused("load config: " + err.Error())
+ }
+ loadedConfig.variant = args.Variant
+ events := newRecordWriter(host, notes)
+ model := injected
+ if model == nil {
+ client := newModelAPIBackend(api, args.Variant)
+ loadedConfig.applyBackend(client)
+ client.events = events
+ catalog, err := loadCatalog(ctx, notes)
+ if err != nil {
+ return refused("model catalog: " + err.Error())
+ }
+ client.catalog = catalog
+ model = client
+ known := func(ref string) bool {
+ providerID, modelID := normalizeModelRef(splitModelID(ref))
+ if _, err := catalog.Resolve(providerID, modelID); err == nil {
+ return true
+ }
+ return len(loadedConfig.model(providerID, modelID)) > 0
+ }
+ if options.Asked {
+ if refusal := askedRefusal(args.High, known); refusal != "" {
+ return refused(refusal)
+ }
+ }
+ if options.Crew {
+ high := args.High
+ args = crewPools(args, known, notes)
+ if options.Asked {
+ args.High = high
+ }
+ }
+ }
+
+ runner := newPipeline(args, workspace, pipelineDeps{
+ Backend: model, Config: loadedConfig, Events: events, Notes: notes,
+ })
+ defer runner.runtime.Close()
+ result, runErr := runner.run(ctx, options.Goal)
+ result, runErr = classifyRunError(ctx, runner, result, runErr)
+ if runErr != nil {
+ _, _ = fmt.Fprintf(notes, "[senior-dev] the run failed: %v\n", runErr)
+ }
+ // The per-agent rollup lands immediately before the terminal record, so
+ // every completed run carries its own account of wall time and model calls.
+ if summaryData := events.summary.data(); summaryData != nil {
+ events.stage("agent-summary", "completed", summaryData)
+ }
+ return endingOf(result)
+}
+
+// loadCatalog is the models.dev catalog the run prices and sizes models from:
+// the cached copy, else a fetch, kept fresh in the background for as long as
+// the run lasts. SENIOR_DEV_MODELS_PATH, SENIOR_DEV_MODELS_URL and
+// SENIOR_DEV_DISABLE_MODELS_FETCH steer it. It is not a model call: it names
+// each model's window and prices, which is what compaction is sized by.
+func loadCatalog(ctx context.Context, notes io.Writer) (modelsdev.Catalog, error) {
+ catalogClient, err := modelsdev.NewFromEnv(version)
+ if err != nil {
+ return nil, err
+ }
+ catalog, err := catalogClient.Get(ctx)
+ if err != nil {
+ return nil, err
+ }
+ catalogClient.StartRefresh(ctx, func(refreshErr error) {
+ _, _ = fmt.Fprintf(notes, "[senior-dev] failed to fetch models.dev: %v\n", refreshErr)
+ })
+ return catalog, nil
+}
+
+// askedRefusal is the sentence for models the person asked for that senior-dev
+// cannot size — it needs each model's window to keep a long run's history in
+// it — or "" when it can size them all.
+func askedRefusal(pool string, known func(string) bool) string {
+ var unknown []string
+ for _, ref := range splitPool(pool) {
+ if !known(ref) {
+ unknown = append(unknown, strings.TrimPrefix(ref, orclient.Service+"/"))
+ }
+ }
+ if len(unknown) == 0 {
+ return ""
+ }
+ return "senior-dev cannot work with " + strings.Join(unknown, ", ") +
+ ": its model catalog does not know how much it can hold, so nothing was started; ask for a model it knows"
+}
+
+// refused is the ending of a run that could not start: its brief, its
+// settings or its model catalog stood in the way, and nothing ran.
+func refused(reason string) delegate.Ending {
+ return delegate.Ending{Status: delegate.StatusCrashed, Message: reason}
+}
+
+// classifyRunError maps a pipeline error onto the terminal result. Crossing a
+// declared budget ceiling is an ordinary ending, not a crash: the run stops
+// where it stopped and reports what it had. Only senior-dev's own failures
+// crash.
+//
+// A STOP FROM OUTSIDE IS NOT A CRASH EITHER. codeaf ends a run with SIGTERM —
+// the person stopped it, or the run it belongs to ended — and the run's
+// context ends with it. The run stops starting new work, ships what it has
+// (ship runs on every ending) and says what is true: a ceiling it had crossed
+// is budget-exhausted, a candidate it had frozen stands, and anything else is
+// work that did not finish, never a program that broke.
+func classifyRunError(ctx context.Context, runner *pipeline, result pipelineResult, runErr error) (pipelineResult, error) {
+ if runErr == nil {
+ return result, nil
+ }
+ result.CostUSD = runner.totalCost()
+ result.WallStart = runner.wallStart
+ if errors.Is(runErr, errRunBudget) {
+ result.Status = delegate.StatusBudget
+ if exhausted, reason := runner.budgetExhausted(); exhausted && reason != "" {
+ result.Reason = reason
+ } else {
+ result.Reason = runErr.Error()
+ }
+ return result, nil
+ }
+ if ctx.Err() != nil {
+ if exhausted, reason := runner.budgetExhausted(); exhausted {
+ result.Status, result.Reason = delegate.StatusBudget, reason
+ return result, nil
+ }
+ result.Status, result.Reason = delegate.StatusFail, "stopped before it finished"
+ if account, _ := result.Terminal["reason"].(string); account != "" {
+ result.Reason += "; " + account
+ }
+ return result, nil
+ }
+ result.Status = delegate.StatusCrashed
+ result.Reason = runErr.Error()
+ return result, runErr
+}
+
+// endingOf is the run's result as the one terminal record codeaf reads.
+//
+// TWO WITNESSES, KEPT APART. Claim is what senior-dev's model said when it
+// submitted; Observed is what senior-dev itself saw when it ran the project's
+// build and tests on the tree it froze. Neither is reconciled into the other,
+// and everything else senior-dev knows about the ending travels beside them
+// in its own spelling.
+//
+// The sentences are written for a person, because codeaf folds them into the
+// commit that lands and the note that says so.
+func endingOf(result pipelineResult) delegate.Ending {
+ extra := map[string]any{}
+ for key, value := range result.Terminal {
+ extra[key] = value
+ }
+ ending := delegate.Ending{
+ Status: result.Status,
+ Message: messageOf(result, extra),
+ CostUSD: result.CostUSD,
+ }
+ if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message {
+ ending.Reason = reason
+ }
+ delete(extra, "reason")
+ ending.Claim, _ = extra["submission_reason"].(string)
+ ending.Observed = observedOf(extra)
+ if len(extra) > 0 {
+ ending.Extra = extra
+ }
+ return ending
+}
+
+// messageOf is the ending in one sentence. A run that submitted is said in
+// terms of what its own check of the project found, which is the fact the
+// status projects; everything else keeps the reason the run gave.
+func messageOf(result pipelineResult, data map[string]any) string {
+ inner, _ := data["status"].(string)
+ switch {
+ case result.Status == delegate.StatusPass && inner == "pass":
+ return "submitted a change, and the project's own build and tests passed"
+ case result.Status == delegate.StatusPass && inner == "pass-unverified":
+ return "submitted a change, and nothing finished checking it"
+ case result.Status == delegate.StatusFail && inner == "fail":
+ return "submitted a change that the project's own build or tests do not pass"
+ }
+ return result.Reason
+}
+
+// observedOf says what senior-dev itself saw of the project's build and tests
+// on the tree the run left, and what it did to that tree, empty when it ran
+// nothing.
+func observedOf(data map[string]any) string {
+ var said []string
+ inner, _ := data["status"].(string)
+ _, checked := data["verification_commands"]
+ commands := wholeNumber(data["verification_commands"])
+ failing := wholeNumber(data["verification_failing"])
+ failure, _ := data["verification_failure"].(string)
+ switch {
+ case inner == "pass-unverified":
+ said = append(said, "nothing finished running the project's build and tests on the submitted change")
+ case !checked:
+ case data["verification_timed_out"] == true:
+ said = append(said, "the project's build and tests did not finish in the time allowed")
+ case failing > 0:
+ said = append(said, fmt.Sprintf("%d of the project's %d build and test commands failed", failing, commands))
+ case failure != "":
+ said = append(said, "the project's check could not run: "+failure)
+ case commands > 0:
+ said = append(said, fmt.Sprintf("the project's %d build and test commands all passed", commands))
+ default:
+ said = append(said, "the project has no build or tests it could find to run")
+ }
+ if data["suite_dead"] == true {
+ said = append(said, "its test suite could not even start")
+ }
+ if source, _ := data["restore_source"].(string); source != "" {
+ said = append(said, "the tree was put back to "+RestoredFrom(source))
+ }
+ return strings.Join(said, "; ")
+}
+
+// RestoredFrom names a restore's source the way a person would: the ending's
+// observation says it here, and senior-dev's page says it with the same words
+// (internal/seniordev's actions.go).
+func RestoredFrom(source string) string {
+ switch source {
+ case "coherent-checkpoint":
+ return "the last state whose build and tests could run"
+ case "starting-tree":
+ return "the tree it started from"
+ case "starting-commit":
+ return "the commit it started from"
+ }
+ return source
+}
+
+// wholeNumber reads a count out of the terminal data, which holds it as an
+// int when the run wrote it and as a float64 once it has been through JSON.
+func wholeNumber(value any) int {
+ switch number := value.(type) {
+ case int:
+ return number
+ case float64:
+ return int(number)
+ }
+ return 0
+}
diff --git a/internal/seniordev/app/run_error_classify_test.go b/internal/seniordev/app/run_error_classify_test.go
new file mode 100644
index 0000000000..b9157dade6
--- /dev/null
+++ b/internal/seniordev/app/run_error_classify_test.go
@@ -0,0 +1,82 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+)
+
+// A budget stop is a truthful, checkpointable terminal — exit 0, status
+// budget-exhausted — however deeply the errRunBudget sentinel is wrapped.
+// Any other error still crashes.
+func TestClassifyRunErrorMapsBudgetSentinelFromAnyPhase(t *testing.T) {
+ runner := newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ t.Cleanup(runner.runtime.Close)
+
+ wrapped := fmt.Errorf("landing turn: %w", fmt.Errorf(
+ "%w: cost $0.6172 >= budget $0.6000", errRunBudget,
+ ))
+ result, err := classifyRunError(context.Background(), runner, pipelineResult{Status: "crashed"}, wrapped)
+ if err != nil {
+ t.Fatalf("budget sentinel returned an error (would exit 1): %v", err)
+ }
+ if result.Status != "budget-exhausted" || !strings.Contains(result.Reason, "cost $0.6172") {
+ t.Fatalf("result = %#v", result)
+ }
+
+ infrastructure := errors.New("provider wiring exploded")
+ result, err = classifyRunError(context.Background(), runner, pipelineResult{Status: "crashed"}, infrastructure)
+ if !errors.Is(err, infrastructure) || result.Status != "crashed" ||
+ result.Reason != "provider wiring exploded" {
+ t.Fatalf("infrastructure error result = %#v err = %v", result, err)
+ }
+
+ passResult := pipelineResult{Status: "pass"}
+ result, err = classifyRunError(context.Background(), runner, passResult, nil)
+ if err != nil || result.Status != "pass" {
+ t.Fatalf("nil error result = %#v err = %v", result, err)
+ }
+}
+
+// codeaf stops a run with SIGTERM, which ends its context. That is a stop, not
+// a program that broke: the run's error is the context's own, and the ending
+// says the work did not finish, carrying the run's own account of how far it
+// got. A ceiling the run had already crossed is still the ceiling.
+func TestAStopFromOutsideIsNotACrash(t *testing.T) {
+ runner := newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ t.Cleanup(runner.runtime.Close)
+ stopped, stop := context.WithCancel(context.Background())
+ stop()
+
+ result, err := classifyRunError(stopped, runner, pipelineResult{
+ Status: "crashed",
+ Terminal: map[string]any{"reason": "no submission: the run stopped without calling submit"},
+ }, context.Canceled)
+ if err != nil {
+ t.Fatalf("a stop returned an error, which the ending would call a crash: %v", err)
+ }
+ if result.Status != "fail" || !strings.HasPrefix(result.Reason, "stopped before it finished") ||
+ !strings.Contains(result.Reason, "without calling submit") {
+ t.Fatalf("stopped result = %#v", result)
+ }
+
+ spent := 1.0
+ budgeted := newPipeline(cliArgs{MaxCost: &spent}, t.TempDir(), pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ t.Cleanup(budgeted.runtime.Close)
+ budgeted.runtime.addCost(2)
+ result, err = classifyRunError(stopped, budgeted, pipelineResult{Status: "crashed"}, context.Canceled)
+ if err != nil || result.Status != "budget-exhausted" {
+ t.Fatalf("a stop past the ceiling = %#v err = %v, want budget-exhausted", result, err)
+ }
+}
diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go
new file mode 100644
index 0000000000..7ccf0b6516
--- /dev/null
+++ b/internal/seniordev/app/runtime.go
@@ -0,0 +1,579 @@
+//go:build !windows
+
+// This file adapts the model backend, tool registry, durable session store and
+// bus into the single `turn` the solo run drives.
+package app
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/id"
+ "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev"
+ "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy"
+ "github.com/Agent-Field/codeaf/internal/seniordev/question"
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore"
+ "github.com/Agent-Field/codeaf/internal/seniordev/storage"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+type turn struct {
+ SessionID string
+ ParentSessionID string
+ MessageID string
+ SessionTitle string
+ Agent string
+ AgentMarkdown string
+ // AgentPromptVerbatim marks AgentMarkdown as a configured prompt string
+ // rather than a baked agent document. A configured `agent.prompt` reaches
+ // the model verbatim; only baked documents carry YAML frontmatter worth
+ // stripping.
+ AgentPromptVerbatim bool
+ Workspace string
+ ProviderID string
+ ModelID string
+ Variant string
+ MaxSteps *float64
+ RawModelCall bool
+ Prompt string
+ SystemInstructions []string
+ LoadInstructions func(context.Context) []string
+ Tools []steploop.ToolDefinition
+ Execute func(context.Context, steploop.ToolCall) (steploop.ToolResult, error)
+ BetweenStepReminder func() string
+ AfterAssistant func(context.Context, string)
+ CompactionDecisions compaction.DecisionSink
+ ModelRequests modelRequestSink
+ Store steploop.Store
+ PromptPersisted bool
+ PromptMessageID string
+ ManageScratch bool
+}
+
+// turnPart is one thing the model produced in a turn: a stretch of text, a
+// compaction summary, or a tool call with the arguments it was given and how
+// it ended.
+type turnPart struct {
+ Type string
+ Text string
+ Tool string
+ ArgsKey string
+ Status string
+ CostUSD *float64
+}
+
+type turnResult struct {
+ // FinishReason is the final assistant's unified engine finish, not text
+ // inference or the finish of an earlier completed step within the turn.
+ FinishReason string
+ SessionID string
+ Text string
+ Parts []turnPart
+ CostUSD float64
+}
+
+type backend interface {
+ Run(context.Context, turn) (turnResult, error)
+}
+
+type runtimeAdapter struct {
+ backend backend
+ registry *tool.Registry
+ config *seniorDevConfig
+ workspace string
+ durable *durableSessions
+ initErr error
+ now func() time.Time
+ ids atomic.Uint64
+ mu sync.Mutex
+ costUSD float64
+ bus *bus.Bus
+ question *question.Service
+ events *eventWriter
+
+ unsubscribeQuestionAutoReject func()
+ unsubscribeEvents func()
+}
+
+// configureTurn is the single provenance seam for every model turn. The
+// event records what the engine will actually execute after baked metadata and
+// project overrides have been resolved; it deliberately hashes prompts rather
+// than copying potentially sensitive project instructions into telemetry.
+func (runtime *runtimeAdapter) configureTurn(value turn) (turn, error) {
+ configured, err := runtime.config.configureTurn(value)
+ if err != nil {
+ return configured, err
+ }
+ runtime.emitTurnProvenance(configured)
+ return configured, nil
+}
+
+func (runtime *runtimeAdapter) emitTurnProvenance(configured turn) {
+ if runtime.events != nil {
+ digest := sha256.Sum256([]byte(configured.AgentMarkdown))
+ data := map[string]any{
+ "agent": configured.Agent, "session_id": configured.SessionID,
+ "provider_id": configured.ProviderID, "model_id": configured.ModelID,
+ "prompt_sha256": fmt.Sprintf("%x", digest[:]),
+ "prompt_verbatim": configured.AgentPromptVerbatim,
+ }
+ if configured.MaxSteps != nil {
+ data["max_steps"] = *configured.MaxSteps
+ }
+ if configured.Variant != "" {
+ data["reasoning_effort"] = configured.Variant
+ }
+ data["compaction"] = runtime.compactionProvenance(configured)
+ runtime.events.stage("agent-runtime", "configured", data)
+ }
+}
+
+// compactionProvenance records the compaction budget the turn will run under:
+// the policy in force and the configured block, and when the model's limits
+// are known, the capacity, the high and low watermarks and the verbatim-tail
+// budget, so the budget a run used can be read back from the event stream.
+func (runtime *runtimeAdapter) compactionProvenance(configured turn) map[string]any {
+ cfg, err := runtime.config.overflowConfig()
+ if concrete, ok := runtime.backend.(*modelAPIBackend); ok && concrete != nil && err == nil {
+ cfg = concrete.withPinnedCapacity(cfg, configured.SessionID)
+ }
+ // Config accepts only the window policy or an empty value, so the policy
+ // in force is always the window.
+ record := map[string]any{"policy": overflow.PolicyWindow}
+ if err != nil {
+ record["error"] = err.Error()
+ return record
+ }
+ if cfg.Compaction != nil {
+ if cfg.Compaction.CapacityTokens != nil {
+ record["configured_capacity_tokens"] = *cfg.Compaction.CapacityTokens
+ }
+ if cfg.Compaction.PreserveRecentTokens != nil {
+ record["configured_preserve_recent_tokens"] = *cfg.Compaction.PreserveRecentTokens
+ }
+ if cfg.Compaction.PreserveRecentFraction != nil {
+ record["configured_preserve_recent_fraction"] = *cfg.Compaction.PreserveRecentFraction
+ }
+ }
+ concrete, ok := runtime.backend.(*modelAPIBackend)
+ if !ok || concrete == nil {
+ return record
+ }
+ _, model, err := (seniorDevModels{backend: concrete, agent: configured.Agent}).projection(
+ configured.ProviderID, configured.ModelID,
+ )
+ if err != nil {
+ record["error"] = err.Error()
+ return record
+ }
+ marks := overflow.Watermarks(overflow.UsableInput{Cfg: cfg, Model: model})
+ if pinned, ok := concrete.pinnedCapacityFor(configured.SessionID); ok {
+ record["pinned_capacity_tokens"] = pinned
+ }
+ record["model_context_tokens"] = model.Limit.Context
+ record["capacity_tokens"] = marks.Capacity
+ record["high_tokens"] = marks.High
+ record["low_tokens"] = marks.Low
+ record["tail_budget_tokens"] = compaction.TailBudget(cfg, marks)
+ return record
+}
+
+func newConfiguredRuntime(workspace string, client backend, cfg *seniorDevConfig) *runtimeAdapter {
+ runtime := &runtimeAdapter{
+ backend: client, config: cfg, workspace: workspace, now: time.Now,
+ }
+ runtime.durable, runtime.initErr = openDurableSessions(context.Background(), workspace)
+ if runtime.durable != nil && runtime.durable.bus != nil {
+ runtime.bus = runtime.durable.bus
+ } else {
+ // Keep the runtime usable enough to report its initialization failure,
+ // while preserving the one-bus invariant for services constructed below.
+ runtime.bus = bus.New(bus.Context{Directory: workspace, Workspace: workspace})
+ }
+ options := cfg.registryOptions()
+ // The registry identifies its client as "cli" unless SENIOR_DEV_CLIENT names
+ // something else.
+ if clientIdentity, ok := os.LookupEnv("SENIOR_DEV_CLIENT"); ok {
+ options.ClientIdentity = clientIdentity
+ } else {
+ options.ClientIdentity = "cli"
+ }
+ runtime.question = question.NewService(runtime.bus, nil)
+ options.Question = runtime.question
+ runtime.registry = tool.NewWithOptions(workspace, options)
+ // Headless senior-dev has nothing attached that could answer question.asked,
+ // so an unanswered question would hang the run for the rest of its wall
+ // clock. Auto-reject through the service's own reject path so the model
+ // receives the rejection ("The user dismissed this question") and the run
+ // keeps moving. The registry converts the third consecutive rejection into
+ // its documented terminal success result.
+ runtime.unsubscribeQuestionAutoReject = runtime.bus.SubscribeCallback(
+ question.Event.Asked, func(payload bus.Payload) {
+ if request, ok := payload.Properties.(question.Request); ok {
+ runtime.question.Reject(request.ID)
+ }
+ })
+ return runtime
+}
+
+func (runtime *runtimeAdapter) nextID(prefix string) string {
+ switch prefix {
+ case "session":
+ value, err := id.Descending("session")
+ if err == nil {
+ return value
+ }
+ case "message":
+ return steploop.NewAscendingID("msg")
+ case "part":
+ return steploop.NewAscendingID("prt")
+ }
+ return fmt.Sprintf("%s_%016x", prefix, runtime.ids.Add(1))
+}
+
+func (runtime *runtimeAdapter) addCost(cost float64) {
+ runtime.mu.Lock()
+ runtime.costUSD += cost
+ runtime.mu.Unlock()
+}
+
+func (runtime *runtimeAdapter) cost() float64 {
+ runtime.mu.Lock()
+ defer runtime.mu.Unlock()
+ return runtime.costUSD
+}
+
+func (runtime *runtimeAdapter) runTurn(ctx context.Context, request turn) (turnResult, error) {
+ if runtime.initErr != nil {
+ return turnResult{}, runtime.initErr
+ }
+ if runtime.backend == nil {
+ return turnResult{}, errors.New("senior-dev runtime: backend is required")
+ }
+ if request.Variant == "" {
+ if concrete, ok := runtime.backend.(*modelAPIBackend); ok {
+ request.Variant = concrete.variant
+ }
+ }
+ request.ProviderID, request.ModelID = normalizeModelRef(request.ProviderID, request.ModelID)
+ if request.SessionID == "" {
+ info, err := runtime.createSession(ctx, sessioncore.CreateInput{
+ ParentID: request.ParentSessionID, Title: request.SessionTitle,
+ Agent: request.Agent, Directory: request.Workspace,
+ Model: sessionModel(request.ProviderID, request.ModelID, request.Variant),
+ })
+ if err != nil {
+ return turnResult{}, err
+ }
+ request.SessionID = info.ID
+ } else if err := runtime.ensureSession(ctx, request); err != nil {
+ return turnResult{SessionID: request.SessionID}, err
+ }
+ request.Store = runtime.durable
+ if request.CompactionDecisions == nil {
+ request.CompactionDecisions = newSeniorDevCompactionDecisionSink(runtime.bus, runtime.events)
+ }
+ if request.ModelRequests == nil {
+ request.ModelRequests = newModelRequestSink(runtime.bus)
+ }
+ messageID, err := persistTurnPrompt(
+ ctx, runtime.durable, request.SessionID, request.MessageID, request,
+ )
+ if err != nil {
+ return turnResult{SessionID: request.SessionID}, err
+ }
+ if err := runtime.durable.TouchSession(ctx, request.SessionID); err != nil {
+ return turnResult{SessionID: request.SessionID}, err
+ }
+ request.PromptPersisted = true
+ request.PromptMessageID = messageID
+ if request.ManageScratch {
+ releaseScratch := tool.AcquireShellScratch(request.SessionID)
+ defer releaseScratch()
+ }
+ return runtime.backend.Run(ctx, request)
+}
+
+func persistTurnPrompt(
+ ctx context.Context,
+ store steploop.Store,
+ sessionID string,
+ messageID string,
+ request turn,
+) (string, error) {
+ if messageID == "" {
+ messageID = steploop.NewAscendingID("msg")
+ }
+ user := msgmodel.User{
+ MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID},
+ Time: msgmodel.TimeCreated{Created: uint64(time.Now().UnixMilli())},
+ Agent: request.Agent,
+ Model: msgmodel.UserModel{
+ ProviderID: request.ProviderID, ModelID: request.ModelID,
+ },
+ }
+ if request.Variant != "" {
+ user.Model.Variant = &request.Variant
+ }
+ parts := []msgmodel.Part{msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{
+ ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID,
+ },
+ Text: request.Prompt,
+ }}
+ if paired, ok := store.(interface {
+ UpdateMessageWithParts(context.Context, msgmodel.Info, ...msgmodel.Part) error
+ }); ok {
+ if err := paired.UpdateMessageWithParts(ctx, user, parts...); err != nil {
+ return "", err
+ }
+ return messageID, nil
+ }
+ for _, part := range parts {
+ if err := store.UpdatePart(ctx, part); err != nil {
+ return "", err
+ }
+ }
+ if err := store.UpdateMessage(ctx, user); err != nil {
+ return "", err
+ }
+ return messageID, nil
+}
+
+func (runtime *runtimeAdapter) ensureSession(ctx context.Context, request turn) error {
+ if runtime.durable == nil {
+ return errors.New("senior-dev runtime: durable sessions are unavailable")
+ }
+ if _, err := runtime.durable.sessions.Get(ctx, request.SessionID); err == nil {
+ return nil
+ } else {
+ var missing *storage.NotFoundError
+ if !errors.As(err, &missing) {
+ return err
+ }
+ }
+ _, err := runtime.createSession(ctx, sessioncore.CreateInput{
+ ID: request.SessionID, ParentID: request.ParentSessionID,
+ Title: request.SessionTitle, Agent: request.Agent, Directory: request.Workspace,
+ Model: sessionModel(request.ProviderID, request.ModelID, request.Variant),
+ })
+ return err
+}
+
+func (runtime *runtimeAdapter) createSession(
+ ctx context.Context, input sessioncore.CreateInput,
+) (sessioncore.Info, error) {
+ if runtime.initErr != nil {
+ return sessioncore.Info{}, runtime.initErr
+ }
+ if runtime.durable == nil {
+ return sessioncore.Info{}, errors.New("senior-dev runtime: durable sessions are unavailable")
+ }
+ info, err := runtime.durable.CreateSession(ctx, input)
+ if err != nil {
+ return sessioncore.Info{}, err
+ }
+ return info, nil
+}
+
+func (runtime *runtimeAdapter) ensureRootSession(
+ ctx context.Context, sessionID, title, agent string,
+) error {
+ return runtime.ensureSession(ctx, turn{
+ SessionID: sessionID, SessionTitle: title, Agent: agent,
+ Workspace: runtime.workspace,
+ })
+}
+
+func sessionModel(providerID, modelID, variant string) *sessioncore.Model {
+ if providerID == "" && modelID == "" && variant == "" {
+ return nil
+ }
+ model := &sessioncore.Model{ID: modelID, ProviderID: providerID}
+ if variant != "" {
+ model.Variant = &variant
+ }
+ return model
+}
+
+func (runtime *runtimeAdapter) Close() {
+ if runtime == nil {
+ return
+ }
+ if runtime.unsubscribeQuestionAutoReject != nil {
+ runtime.unsubscribeQuestionAutoReject()
+ }
+ if runtime.unsubscribeEvents != nil {
+ runtime.unsubscribeEvents()
+ }
+ if runtime.question != nil {
+ runtime.question.Close()
+ }
+ if runtime.durable != nil {
+ runtime.durable.Close()
+ } else if runtime.bus != nil {
+ runtime.bus.Dispose()
+ }
+}
+
+// policyDisabledTools names the builtin tools the network policy withholds
+// from the model entirely: with egress off, the web tools disappear from the
+// tool list so the model never sees, plans around, or probes them.
+func policyDisabledTools(policy netpolicy.Policy) []string {
+ if policy.Restricted() {
+ return []string{"webfetch", "websearch"}
+ }
+ return nil
+}
+
+func filterTools(
+ definitions []steploop.ToolDefinition, disabled map[string]bool,
+) []steploop.ToolDefinition {
+ out := make([]steploop.ToolDefinition, 0, len(definitions))
+ for _, definition := range definitions {
+ if !disabled[definition.Provider.Name] {
+ out = append(out, definition)
+ }
+ }
+ return out
+}
+
+func (runtime *runtimeAdapter) definitionsFor(
+ providerID, modelID, agentName string, disabled map[string]bool,
+) []steploop.ToolDefinition {
+ if disabled == nil {
+ disabled = map[string]bool{}
+ }
+ // Visibility gating lives here rather than in tool.FilterDefinitions.
+ // With the tools absent from the definitions
+ // the model never plans around them, so it cannot burn turns retrying
+ // policy errors; the execute-time checks remain as defense in depth.
+ for _, name := range policyDisabledTools(netpolicy.Current()) {
+ disabled[name] = true
+ }
+ for name := range runtime.config.disabledTools(agentName, runtime.registry.IDs()) {
+ disabled[name] = true
+ }
+ definitions := tool.FilterDefinitions(runtime.registry.Definitions(), tool.FilterInput{
+ ProviderID: providerID,
+ ModelID: modelID,
+ Flags: tool.CurrentWebSearchFlags(),
+ })
+ return filterTools(definitions, disabled)
+}
+
+// poolResolver holds the model pools the run was started with and answers
+// which one a tier routes on. A tier given no pool of its own routes on the
+// high pool, the same degradation the router applies.
+type poolResolver struct {
+ high []string
+ low []string
+ frontier []string
+}
+
+func (resolver poolResolver) values(tier baked.Tier) []string {
+ pool := resolver.high
+ switch tier {
+ case baked.TierLow:
+ if len(resolver.low) > 0 {
+ pool = resolver.low
+ }
+ case baked.TierFrontier:
+ if len(resolver.frontier) > 0 {
+ pool = resolver.frontier
+ }
+ }
+ return append([]string{}, pool...)
+}
+
+// modelAPIBackend runs the model turns of one run against the model API codeaf
+// serves it: an endpoint that answers in OpenRouter's chat-completions shape,
+// opened by a token that opens nothing else.
+//
+// IT HOLDS NO KEY. senior-dev read a provider key and a base URL out of its
+// environment before codeaf carried it; both reads are gone, and so is every
+// check that a key was set. The API's address and token arrive through the
+// delegate.Host, and fetch is the one door every model request leaves by.
+type modelAPIBackend struct {
+ api delegate.ModelAPI
+ variant string
+ client *http.Client
+ contextLimit float64
+ outputLimit float64
+ totalTimeoutMS float64
+ chunkTimeoutMS float64
+ config *seniorDevConfig
+ router *adaptive.AdaptiveModelRouter
+ catalog modelsdev.Catalog
+ // events receives the records the backend emits on its own, after
+ // configureTurn: the compaction-capacity pins (compaction_pin.go).
+ events *eventWriter
+ // pinnedCapacity is the per-session capacity a context-overflow rejection
+ // named (compaction_pin.go). A run is one process, so the map is the
+ // whole of the state.
+ pinMu sync.Mutex
+ pinnedCapacity map[string]float64
+}
+
+func executeAdvertisedTool(
+ ctx context.Context, request turn, call steploop.ToolCall,
+) (steploop.ToolResult, error) {
+ available := make([]string, 0, len(request.Tools))
+ for _, definition := range request.Tools {
+ name := definition.Provider.Name
+ available = append(available, name)
+ if name == call.Name {
+ return request.Execute(ctx, call)
+ }
+ }
+ message := "Model tried to call unavailable tool '" + call.Name + "'. "
+ if len(available) == 0 {
+ message += "No tools are available."
+ } else {
+ message += "Available tools: " + strings.Join(available, ", ") + "."
+ }
+ return steploop.ToolResult{}, errors.New(message)
+}
+
+// newModelAPIBackend is the backend of a run whose model API is api.
+func newModelAPIBackend(api delegate.ModelAPI, variant string) *modelAPIBackend {
+ return &modelAPIBackend{
+ api: api, variant: variant,
+ // Streaming lifetime belongs to the caller context and the reader's
+ // inactivity watchdog. http.Client.Timeout measures total request age,
+ // including a healthy response body, so it must remain unset.
+ client: &http.Client{},
+ }
+}
+
+// fetch sends one model request: the model API's token goes on here and
+// nowhere else, over the backend's one HTTP client.
+//
+// THIS IS THE ONE DOOR. The streaming client builds each request and hands it
+// here (orclient.Client.Fetcher), so no request can leave without the token,
+// and none can carry a credential of anybody else's: whatever a configured
+// header said, the Authorization header is the API's, set last.
+func (backend *modelAPIBackend) fetch(request *http.Request) (*http.Response, error) {
+ backend.api.Authorize(request)
+ client := backend.client
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return client.Do(request)
+}
diff --git a/internal/seniordev/app/runtime_compaction_test.go b/internal/seniordev/app/runtime_compaction_test.go
new file mode 100644
index 0000000000..23f4523661
--- /dev/null
+++ b/internal/seniordev/app/runtime_compaction_test.go
@@ -0,0 +1,677 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard"
+)
+
+type scriptedRoundTripper struct {
+ mu sync.Mutex
+ replies []string
+ statuses []int
+ requests [][]byte
+}
+
+type recordedChatMessage struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+}
+
+func (transport *scriptedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+ transport.mu.Lock()
+ defer transport.mu.Unlock()
+ body, err := io.ReadAll(request.Body)
+ if err != nil {
+ return nil, err
+ }
+ transport.requests = append(transport.requests, body)
+ reply := transport.replies[0]
+ transport.replies = transport.replies[1:]
+ status := http.StatusOK
+ if len(transport.statuses) > 0 {
+ status = transport.statuses[0]
+ transport.statuses = transport.statuses[1:]
+ }
+ return &http.Response{
+ StatusCode: status,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(reply)),
+ Request: request,
+ }, nil
+}
+
+func chatReply(content string, promptTokens float64) string {
+ encodedContent, _ := json.Marshal(content)
+ return `data: {"id":"gen-text","choices":[{"delta":{"content":` +
+ string(encodedContent) + `}}]}` + "\n\n" +
+ `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"cost":0.01,"prompt_tokens":` +
+ strconv.FormatFloat(promptTokens, 'f', -1, 64) +
+ `,"completion_tokens":10,"total_tokens":` +
+ strconv.FormatFloat(promptTokens+10, 'f', -1, 64) + `}}` + "\n\n" +
+ "data: [DONE]\n\n"
+}
+
+// summaryPathConfig is a project config with a zero verbatim-tail budget. The
+// histories these tests build are a few hundred tokens, which the default
+// 20K-token tail would keep whole -- leaving nothing to summarize and no
+// summary request for the scripted transport to answer. A zero budget keeps
+// only the newest message verbatim, so every compaction here takes the summary
+// path through the real transport, which is what these tests exist to prove.
+func summaryPathConfig(t *testing.T) *seniorDevConfig {
+ t.Helper()
+ workspace := t.TempDir()
+ if err := os.WriteFile(
+ filepath.Join(workspace, "senior-dev.json"),
+ []byte(`{"compaction":{"preserve_recent_tokens":0}}`), 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return loaded
+}
+
+func validCompactionSummary(goal string) string {
+ return strings.Join([]string{
+ "## Working State",
+ "### Completed", "- " + goal,
+ "### Current", "- continue",
+ "### Verification", "- (none)",
+ "### Next", "- continue",
+ "### Files", "- (none)",
+ }, "\n")
+}
+
+func TestSeniorDevCompactionSizerIncludesSystemPromptAndToolSchemas(t *testing.T) {
+ model := compaction.Model{Message: msgmodel.Model{
+ ProviderID: "openrouter", ID: "vendor/model",
+ }}
+ base, err := (seniorDevContextSizer{}).EstimateContext(context.Background(), nil, model)
+ if err != nil {
+ t.Fatal(err)
+ }
+ large := strings.Repeat("context-bearing-token ", 500)
+ full, err := (seniorDevContextSizer{
+ system: func(context.Context) string { return large },
+ tools: []steploop.ToolDefinition{{Provider: orclient.Tool{
+ Type: "function", Name: "large_tool",
+ Description: large, InputSchema: json.RawMessage(`{"type":"object"}`),
+ }}},
+ }).EstimateContext(context.Background(), nil, model)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if full <= base+4_000 {
+ t.Fatalf("full request estimate = %v, base = %v; system/tool context was not counted", full, base)
+ }
+}
+
+func toolCallReply(name, arguments string) string {
+ encodedName, _ := json.Marshal(name)
+ encodedArguments, _ := json.Marshal(arguments)
+ return `data: {"choices":[{"delta":{"tool_calls":[{` +
+ `"index":0,"id":"call-1","type":"function","function":{"name":` + string(encodedName) +
+ `,"arguments":` + string(encodedArguments) + `}}]},"finish_reason":"tool_calls"}],` +
+ `"usage":{"cost":0.01,"prompt_tokens":10,"completion_tokens":10,"total_tokens":20}}` +
+ "\n\ndata: [DONE]\n\n"
+}
+
+func TestOpenRouterRejectsToolOmittedFromRequestDefinitions(t *testing.T) {
+ // An unavailable write projects through the synthetic invalid tool as a
+ // successful correction, without mutating disk.
+ workspace := t.TempDir()
+ target := filepath.Join(workspace, "forbidden.txt")
+ arguments, err := json.Marshal(map[string]any{
+ "filePath": target,
+ "content": "must not be written",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("write", string(arguments)),
+ chatReply("continued after rejection", 10),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ }
+ runtime := newRuntime(workspace, backend)
+ t.Cleanup(runtime.Close)
+ result, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ModelID: "openai/gpt-6.1-codex",
+ Workspace: workspace, Prompt: "test filtered execution",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
+ t.Fatalf("filtered write changed the workspace: %v", statErr)
+ }
+ if len(result.Parts) != 2 || result.Parts[0].Type != "tool" ||
+ result.Parts[0].Tool != "invalid" || result.Parts[0].Status != "completed" ||
+ result.Parts[1].Text != "continued after rejection" {
+ t.Fatalf("turn parts = %#v", result.Parts)
+ }
+ if len(transport.requests) != 2 {
+ t.Fatalf("HTTP requests = %d, want rejected turn plus continuation", len(transport.requests))
+ }
+ for _, part := range result.Parts {
+ if part.Type == "tool" && part.Status == "error" {
+ t.Fatalf("synthetic invalid call counted as a tool error: %#v", part)
+ }
+ }
+ want := "The arguments provided to the tool are invalid: Model tried to call unavailable tool 'write'."
+ if !strings.Contains(string(transport.requests[1]), want) {
+ t.Fatalf("model-visible rejection = %s, want substring %q", transport.requests[1], want)
+ }
+}
+
+func TestOpenRouterSystemIncludesRootInstructionsAndReadOnlyInjectsNestedRules(t *testing.T) {
+ // Root AGENTS.md is in every engine system message, while only a read
+ // below a nested rules file gets a
+ // nested system-reminder (the root path is excluded from Resolve).
+ workspace := t.TempDir()
+ rootRules := filepath.Join(workspace, "AGENTS.md")
+ if err := os.WriteFile(rootRules, []byte("ROOT ENGINE CONTRACT"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(workspace, "root.txt"), []byte("root target"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ nested := filepath.Join(workspace, "src")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("NESTED READ CONTRACT"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nested, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ runRead := func(target string) [][]byte {
+ t.Helper()
+ arguments, err := json.Marshal(map[string]string{"filePath": target})
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("read", string(arguments)), chatReply("done", 10),
+ }}
+ runtime := newRuntime(workspace, &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ })
+ if _, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ModelID: "vendor/model",
+ Workspace: workspace, Prompt: "read the target",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return transport.requests
+ }
+
+ rootRequests := runRead(filepath.Join(workspace, "root.txt"))
+ if !strings.Contains(string(rootRequests[0]), "ROOT ENGINE CONTRACT") {
+ t.Fatalf("root instruction missing from system message: %s", rootRequests[0])
+ }
+ if strings.Contains(string(rootRequests[1]), "") {
+ t.Fatalf("root-level read injected a nested reminder: %s", rootRequests[1])
+ }
+
+ nestedRequests := runRead(filepath.Join(nested, "main.go"))
+ if !strings.Contains(string(nestedRequests[0]), "ROOT ENGINE CONTRACT") ||
+ !strings.Contains(string(nestedRequests[1]),
+ "\\nInstructions from: "+filepath.Join(nested, "AGENTS.md")+"\\nNESTED READ CONTRACT") {
+ t.Fatalf("root/nested instruction projection = %s", nestedRequests[1])
+ }
+}
+
+func TestOpenRouterCompactsContextAndContinues(t *testing.T) {
+ // Inflated usage shrinks the next live iteration to [system, original
+ // user, summary context] and the loop keeps advancing.
+ transport := &scriptedRoundTripper{replies: []string{
+ chatReply("working", 70_000),
+ chatReply(validCompactionSummary("anchored summary for the original task"), 10),
+ chatReply("finished after compaction", 10),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model",
+ Prompt: "original task",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "finished after compaction" {
+ t.Fatalf("result text = %q", result.Text)
+ }
+ if len(transport.requests) != 3 {
+ t.Fatalf("HTTP requests = %d, want response + summary + continued response", len(transport.requests))
+ }
+ var summary map[string]json.RawMessage
+ if err := json.Unmarshal(transport.requests[1], &summary); err != nil {
+ t.Fatal(err)
+ }
+ if _, exists := summary["tools"]; exists {
+ t.Fatalf("summary request serialized tools: %s", transport.requests[1])
+ }
+ var summaryMessages []recordedChatMessage
+ if err := json.Unmarshal(summary["messages"], &summaryMessages); err != nil {
+ t.Fatal(err)
+ }
+ systemCount := 0
+ for _, message := range summaryMessages {
+ if message.Role != "system" {
+ continue
+ }
+ systemCount++
+ var content []struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(message.Content, &content); err != nil {
+ t.Fatal(err)
+ }
+ texts := make([]string, 0, len(content))
+ for _, part := range content {
+ texts = append(texts, part.Text)
+ }
+ if got := strings.Join(texts, "\n"); got != compaction.SummarySystemPrompt {
+ t.Fatalf("summary system prompt = %q", got)
+ }
+ }
+ if systemCount != 1 {
+ t.Fatalf("summary system message count = %d; messages=%#v", systemCount, summaryMessages)
+ }
+ var continued struct {
+ Messages []recordedChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(transport.requests[2], &continued); err != nil {
+ t.Fatal(err)
+ }
+ if len(continued.Messages) < 3 || continued.Messages[0].Role != "system" {
+ t.Fatalf("continued context = %#v", continued.Messages)
+ }
+ continuedJSON := string(transport.requests[2])
+ if !strings.Contains(continuedJSON, "anchored summary") ||
+ !strings.Contains(continuedJSON, "Continue from the current state") ||
+ !strings.Contains(continuedJSON, "working") {
+ t.Fatalf("continued context = %s", transport.requests[2])
+ }
+ // The summary request carried the flattened head -- the original task --
+ // and not the verbatim tail, and it carried it as real content.
+ summaryJSON := string(transport.requests[1])
+ if !strings.Contains(summaryJSON, `\n[User]: original task`) ||
+ strings.Contains(summaryJSON, `[Assistant]: working`) {
+ t.Fatalf("summary request = %s", summaryJSON)
+ }
+}
+
+func TestProjectConfigDisablesAutoCompactionOnLiveTurn(t *testing.T) {
+ // compaction.auto=false loaded from project config reaches the live
+ // controller and suppresses an otherwise-overflowing turn.
+ workspace := t.TempDir()
+ if err := os.WriteFile(
+ filepath.Join(workspace, "senior-dev.json"),
+ []byte(`{"compaction":{"auto":false}}`), 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := loadSeniorDevConfig(workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport := &scriptedRoundTripper{replies: []string{
+ chatReply("finished without compaction", 70_000),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ }
+ loaded.applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model",
+ Workspace: workspace, Prompt: "original task",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "finished without compaction" {
+ t.Fatalf("result text = %q", result.Text)
+ }
+ if len(transport.requests) != 1 {
+ t.Fatalf("HTTP requests = %d, want one un-compacted turn", len(transport.requests))
+ }
+}
+
+func TestOpenRouterCompactionHarvestsEvidenceByCodeAlone(t *testing.T) {
+ // Evidence is harvested from the summarized head by code: the failing-test
+ // signature survives the boundary, and no second model is asked anything
+ // -- exactly four requests, all to the coder's own model.
+ transport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("bash", `{"command":"go test ./..."}`),
+ chatReply("working before compaction", 70_000),
+ chatReply(validCompactionSummary("fix the widget"), 10),
+ chatReply("finished", 10),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", ModelID: "vendor/model", Workspace: t.TempDir(), Prompt: "fix the widget",
+ AgentMarkdown: testAgentPrompt,
+ Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{
+ Type: "function", Name: "bash", InputSchema: json.RawMessage(`{"type":"object"}`),
+ }}},
+ Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) {
+ return steploop.ToolResult{
+ Title: "go test ./...",
+ Output: "FAILED tests/widget_test.go::TestWidget\nAssertionError: got 2, want 3\n1 failed",
+ }, nil
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Parts) < 2 || result.Parts[0].Type != "compaction" ||
+ !strings.Contains(result.Parts[0].Text, "FAILED tests/widget_test.go::TestWidget") ||
+ !strings.Contains(result.Parts[0].Text, "AssertionError: got 2, want 3") {
+ t.Fatalf("compaction projection = %#v", result.Parts)
+ }
+ if len(transport.requests) != 4 {
+ t.Fatalf("HTTP requests = %d, want tool turn + overflow turn + summary + continuation", len(transport.requests))
+ }
+ for index, request := range transport.requests {
+ if strings.Contains(string(request), "cheap/evidence-model") {
+ t.Fatalf("request %d went to the evidence model: %s", index, request)
+ }
+ }
+}
+
+func TestOpenRouterCompactionResetsTheObservationWindow(t *testing.T) {
+ // Compaction leaves one explicit boundary plus only post-compaction
+ // actions/messages for the loop guard and context counters.
+ workspace := t.TempDir()
+ transport := &scriptedRoundTripper{replies: []string{
+ strings.Replace(toolCallReply("write", `{}`), `"prompt_tokens":10`, `"prompt_tokens":70000`, 1),
+ chatReply(validCompactionSummary("summary after rejected stale call"), 10),
+ chatReply("finished in fresh window", 10),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ runtime := newRuntime(workspace, backend)
+ t.Cleanup(runtime.Close)
+ result, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ModelID: "openai/gpt-6.1-codex",
+ Workspace: workspace, Prompt: "compact the history",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Parts) != 2 || result.Parts[0].Type != "compaction" ||
+ !strings.Contains(result.Parts[0].Text, "summary after rejected stale call") ||
+ result.Parts[1].Type != "text" || result.Parts[1].Text != "finished in fresh window" {
+ t.Fatalf("turn parts = %#v", result.Parts)
+ }
+ guard := loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{})
+ for _, part := range result.Parts {
+ if part.Type == "tool" {
+ guard.Observe(loopguard.LoopAction{Tool: part.Tool, ArgsKey: part.ArgsKey})
+ }
+ }
+ if got := guard.Snapshot().ActionCount; got != 0 {
+ t.Fatalf("post-compaction loop actions = %v, want 0", got)
+ }
+ if result.CostUSD < 0.029 || result.CostUSD > 0.031 {
+ t.Fatalf("post-compaction cost = %v, want the three calls' 0.03", result.CostUSD)
+ }
+}
+
+func TestOpenRouterSummaryFailureInstallsRecordAndContinues(t *testing.T) {
+ // A failed summary call is not a dead run: the boundary completes with the
+ // deterministic record after exactly one attempt, the verbatim tail is kept,
+ // the turn goes on, and completed live-call cost is still recorded. A 502 is
+ // deliberate: a retryable status must not make the summary request replay.
+ transport := &scriptedRoundTripper{
+ replies: []string{
+ chatReply("working", 70_000),
+ `{"error":{"message":"summary provider unavailable"}}`,
+ chatReply("finished after a failed summary", 10),
+ },
+ statuses: []int{http.StatusOK, http.StatusBadGateway, http.StatusOK},
+ }
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ runtime := newRuntime(t.TempDir(), backend)
+ t.Cleanup(runtime.Close)
+ result, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ModelID: "vendor/model",
+ Workspace: t.TempDir(), Prompt: "original task",
+ })
+ if err != nil {
+ t.Fatalf("a failed summary killed the turn: %v", err)
+ }
+ if result.Text != "finished after a failed summary" || len(transport.requests) != 3 {
+ t.Fatalf("result=%q requests=%d", result.Text, len(transport.requests))
+ }
+ if len(result.Parts) == 0 || result.Parts[0].Type != "compaction" ||
+ !strings.Contains(result.Parts[0].Text, "no state record could be generated") {
+ t.Fatalf("compaction projection = %#v", result.Parts)
+ }
+ continued := string(transport.requests[2])
+ if !strings.Contains(continued, "working") || !strings.Contains(continued, "original task") {
+ t.Fatalf("continuation lost the tail or the pinned task: %s", continued)
+ }
+ if got := runtime.cost(); got < 0.019 || got > 0.021 {
+ t.Fatalf("recorded cost = %v, want the two completed live calls", got)
+ }
+}
+
+func TestOpenRouterHardOverflowCompactsAndRetries(t *testing.T) {
+ // A hard provider overflow takes the same capped summary path as
+ // usage-based overflow, then retries with rebuilt context.
+ transport := &scriptedRoundTripper{
+ replies: []string{
+ `{"error":{"message":"maximum context length is 128000 tokens"}}`,
+ chatReply(validCompactionSummary("anchored summary"), 10),
+ chatReply("finished after hard overflow", 10),
+ },
+ statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK},
+ }
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model",
+ Prompt: "original task",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "finished after hard overflow" || len(transport.requests) != 3 {
+ t.Fatalf("result=%+v requests=%d", result, len(transport.requests))
+ }
+}
+
+func TestOpenRouterAllowsMoreThanThreeSuccessfulCompactions(t *testing.T) {
+ // Compaction count is not a termination policy. A long but reducible run
+ // can compact repeatedly and still reach its natural terminal response.
+ const compactions = 5
+ replies := []string{}
+ for index := 0; index < compactions; index++ {
+ replies = append(replies,
+ chatReply("overflow", 70_000),
+ chatReply(validCompactionSummary("task"), 10),
+ )
+ }
+ replies = append(replies, chatReply("natural stop", 10))
+ transport := &scriptedRoundTripper{replies: replies}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system", ModelID: "vendor/model", Prompt: "task",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "natural stop" {
+ t.Fatalf("turn result = %q", result.Text)
+ }
+ if len(transport.requests) != 2*compactions+1 {
+ t.Fatalf("HTTP requests = %d, want %d live/summary requests", len(transport.requests), 2*compactions+1)
+ }
+}
+
+func TestOpenRouterStopsWhenAuthoritativeTaskCannotFitAfterRebuild(t *testing.T) {
+ // Unlimited successful compactions must not become an infinite retry loop.
+ // If the durable task itself cannot leave continuation headroom, fail with
+ // an explicit capacity error after one model summary and one local rebuild.
+ workspace := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(workspace, ".senior-dev", "spec.md"),
+ []byte(strings.Repeat("irreducible authoritative requirement ", 10_000)),
+ 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ transport := &scriptedRoundTripper{replies: []string{
+ chatReply("overflow", 70_000),
+ chatReply(validCompactionSummary("task"), 10),
+ }}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ contextLimit: 128_000, outputLimit: 32_768,
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ _, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system", ModelID: "vendor/model",
+ Workspace: workspace, Prompt: "task",
+ })
+ if !errors.Is(err, compaction.ErrContextCapacityExhausted) {
+ t.Fatalf("error = %v, want context capacity exhausted", err)
+ }
+ if len(transport.requests) != 2 {
+ t.Fatalf("HTTP requests = %d, want live request plus one summary", len(transport.requests))
+ }
+}
+
+func TestOpenRouterEngineHasNoUnconditionalSixtyFourTurnCap(t *testing.T) {
+ // The engine has no unconditional turn cap; action, loop, cost, and agent
+ // step budgets own termination. A valid 65-tool-turn
+ // sequence must therefore reach its natural terminal response.
+ replies := make([]string, 0, 66)
+ for index := 0; index < 65; index++ {
+ replies = append(replies, toolCallReply("bash", `{"command":"true"}`))
+ }
+ replies = append(replies, chatReply("natural stop", 10))
+ transport := &scriptedRoundTripper{replies: replies}
+ backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: transport}}
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", ModelID: "vendor/model", Workspace: t.TempDir(), Prompt: "keep going",
+ AgentMarkdown: testAgentPrompt,
+ Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{
+ Type: "function", Name: "bash", InputSchema: json.RawMessage(`{"type":"object"}`),
+ }}},
+ Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) {
+ return steploop.ToolResult{Output: "ok"}, nil
+ },
+ })
+ if err != nil || result.Text != "natural stop" || len(transport.requests) != 66 {
+ t.Fatalf("result=%+v err=%v requests=%d", result, err, len(transport.requests))
+ }
+}
+
+func TestOpenRouterReloadsRootInstructionsEachTurn(t *testing.T) {
+ // A root instruction created by turn one appears in turn two's system
+ // message.
+ workspace := t.TempDir()
+ rules := filepath.Join(workspace, "AGENTS.md")
+ arguments, err := json.Marshal(map[string]string{
+ "filePath": rules, "content": "MID-TURN ROOT CONTRACT",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport := &scriptedRoundTripper{replies: []string{
+ toolCallReply("write", string(arguments)), chatReply("done", 10),
+ }}
+ runtime := newRuntime(workspace, &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ })
+ t.Cleanup(runtime.Close)
+ if _, err := runTestTurn(t, runtime, testTurn{
+ Agent: "coder", ModelID: "vendor/model",
+ Workspace: workspace, Prompt: "create instructions",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(transport.requests[0]), "MID-TURN ROOT CONTRACT") {
+ t.Fatalf("turn one unexpectedly contained future instructions: %s", transport.requests[0])
+ }
+ if !strings.Contains(string(transport.requests[1]), "MID-TURN ROOT CONTRACT") {
+ t.Fatalf("turn two did not reload root instructions: %s", transport.requests[1])
+ }
+}
+
+func TestOpenRouterEmptyBodyOverflowCompacts(t *testing.T) {
+ // An empty 400 body must classify as context overflow ("400 (no body)")
+ // and take the summary path.
+ transport := &scriptedRoundTripper{
+ replies: []string{
+ ``,
+ chatReply(validCompactionSummary("empty-body summary"), 10),
+ chatReply("finished after empty-body overflow", 10),
+ },
+ statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK},
+ }
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: &http.Client{Transport: transport},
+ }
+ summaryPathConfig(t).applyBackend(backend)
+ result, err := backend.Run(context.Background(), turn{
+ Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model",
+ Prompt: "original task",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Text != "finished after empty-body overflow" || len(transport.requests) != 3 {
+ t.Fatalf("result=%+v requests=%d", result, len(transport.requests))
+ }
+}
diff --git a/internal/seniordev/app/runtime_retry_test.go b/internal/seniordev/app/runtime_retry_test.go
new file mode 100644
index 0000000000..81f025041a
--- /dev/null
+++ b/internal/seniordev/app/runtime_retry_test.go
@@ -0,0 +1,260 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+func retryTurn() turn {
+ return turn{ModelID: "test/model", Prompt: "hello", AgentMarkdown: testAgentPrompt}
+}
+
+func TestModelCallNeverRetriesInsideTheEngine(t *testing.T) {
+ // A provider response belongs to exactly one HTTP request. Transient recovery
+ // happens at soloConverse, where it is globally bounded and session-aware.
+ for _, status := range []int{
+ http.StatusBadRequest,
+ http.StatusUnauthorized,
+ http.StatusRequestTimeout,
+ http.StatusConflict,
+ http.StatusTooManyRequests,
+ http.StatusInternalServerError,
+ http.StatusServiceUnavailable,
+ } {
+ t.Run(fmt.Sprintf("status-%d", status), func(t *testing.T) {
+ requests := 0
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ encoded, _ := json.Marshal(map[string]any{
+ "error": map[string]any{"message": "provider failure"},
+ })
+ return recordedResponse(request, status, "application/json", string(encoded)), nil
+ })}
+
+ backend := &modelAPIBackend{api: testModelAPI, client: client}
+ _, err := backend.Run(context.Background(), retryTurn())
+ if err == nil {
+ t.Fatal("provider failure returned nil")
+ }
+ if requests != 1 {
+ t.Fatalf("HTTP requests = %d, want exactly 1", requests)
+ }
+ var failure *modelTurnError
+ if !errors.As(err, &failure) || failure.statusCode == nil ||
+ *failure.statusCode != uint64(status) {
+ t.Fatalf("turn error = %#v, want structured status %d", err, status)
+ }
+ })
+ }
+}
+
+func TestInBandProviderFailureReachesRunClassifierWithStatus(t *testing.T) {
+ requests := 0
+ body := `data: {"error":{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}},"choices":[]}` +
+ "\n\ndata: [DONE]\n\n"
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ return recordedResponse(request, http.StatusOK, "text/event-stream", body), nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client}
+ _, err := backend.Run(context.Background(), retryTurn())
+ if err == nil || requests != 1 {
+ t.Fatalf("turn error=%v requests=%d, want one failed request", err, requests)
+ }
+ info, transient := transientTurnError(err)
+ if !transient || info.Class != "provider-5xx" || info.StatusCode == nil ||
+ *info.StatusCode != 502 || info.ProviderCode != "provider_unavailable" {
+ t.Fatalf("run classification = %#v,%v for %v", info, transient, err)
+ }
+}
+
+type errorAfterBody struct {
+ payload []byte
+ offset int
+}
+
+func (body *errorAfterBody) Read(target []byte) (int, error) {
+ if body.offset >= len(body.payload) {
+ return 0, io.ErrUnexpectedEOF
+ }
+ n := copy(target, body.payload[body.offset:])
+ body.offset += n
+ return n, nil
+}
+
+func (*errorAfterBody) Close() error { return nil }
+
+type errorAfterFile struct {
+ payload []byte
+ offset int
+ path string
+}
+
+func (body *errorAfterFile) Read(target []byte) (int, error) {
+ if body.offset < len(body.payload) {
+ n := copy(target, body.payload[body.offset:])
+ body.offset += n
+ return n, nil
+ }
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ if _, err := os.Stat(body.path); err == nil || time.Now().After(deadline) {
+ return 0, io.ErrUnexpectedEOF
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func (*errorAfterFile) Close() error { return nil }
+
+func TestFailureAfterToolCallDoesNotReplayRequestOrTool(t *testing.T) {
+ requests := 0
+ payload := strings.TrimSuffix(toolCallReply("bash", `{"command":"true"}`), "data: [DONE]\n\n")
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: &errorAfterBody{payload: []byte(payload)},
+ Request: request,
+ }, nil
+ })}
+ backend := &modelAPIBackend{api: testModelAPI, client: client, chunkTimeoutMS: -1}
+ var executions atomic.Int32
+ _, err := backend.Run(context.Background(), turn{
+ Agent: "coder", ModelID: "test/model", Prompt: "use the tool", Workspace: t.TempDir(),
+ AgentMarkdown: testAgentPrompt,
+ Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{
+ Type: "function", Name: "bash", Description: "run a command",
+ InputSchema: json.RawMessage(`{"type":"object"}`),
+ }}},
+ Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) {
+ executions.Add(1)
+ return steploop.ToolResult{Output: "ok"}, nil
+ },
+ })
+ if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unexpected eof") {
+ t.Fatalf("turn error = %v, want the dropped stream", err)
+ }
+ if requests != 1 || executions.Load() != 1 {
+ t.Fatalf("requests=%d tool executions=%d, want 1 and 1", requests, executions.Load())
+ }
+}
+
+func TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffects(t *testing.T) {
+ const effect = "RECOVERY_SIDE_EFFECT_48291"
+ workspace, base := guardWorkspace(t)
+ if err := writeFile(
+ filepath.Join(workspace, ".senior-dev", "checklist.md"),
+ "[x] preserve completed tool effects across recovery\n",
+ ); err != nil {
+ t.Fatal(err)
+ }
+
+ var requestBodies [][]byte
+ var events bytes.Buffer
+ client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ raw, err := io.ReadAll(request.Body)
+ if err != nil {
+ return nil, err
+ }
+ requestBodies = append(requestBodies, raw)
+
+ var reply string
+ switch len(requestBodies) {
+ case 1:
+ reply = toolCallReply("bash", `{"command":"printf '`+effect+`\\n' > recovered.txt"}`)
+ case 2:
+ reply = strings.Replace(
+ toolCallReply("submit", `{"reason":"recovered safely","evidence":"workspace effect inspected","checklist_satisfied":true}`),
+ "call-1", "call-submit", 1,
+ )
+ return recordedResponse(request, http.StatusOK, "text/event-stream", reply), nil
+ case 3:
+ return recordedResponse(request, http.StatusOK, "text/event-stream", chatReply("done", 10)), nil
+ default:
+ t.Fatalf(
+ "unexpected model request %d (recovery=%v nudge=%v)",
+ len(requestBodies), strings.Contains(string(raw), soloRecoveryPrompt()),
+ strings.Contains(string(raw), "You stopped without calling submit"),
+ )
+ }
+ // Only the first response drops after executing a tool. The recovered
+ // turn is healthy and can submit the preserved workspace normally.
+ payload := strings.TrimSuffix(reply, "data: [DONE]\n\n")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: &errorAfterFile{
+ payload: []byte(payload), path: filepath.Join(workspace, "recovered.txt"),
+ },
+ Request: request,
+ }, nil
+ })}
+ backend := &modelAPIBackend{
+ api: testModelAPI, client: client, totalTimeoutMS: -1, chunkTimeoutMS: -1,
+ }
+ runner := newPipeline(cliArgs{High: "openrouter/test/model"}, workspace, pipelineDeps{
+ Backend: backend, Events: newEventWriter(&events), Notes: discardWriter{},
+ Sleep: func(context.Context, time.Duration) error { return nil },
+ })
+ t.Cleanup(runner.runtime.Close)
+ // The adaptive router is orthogonal to this test. Keeping its single
+ // candidate out of cooldown lets the fresh turn start immediately.
+ backend.router = nil
+ state := &soloState{baseSHA: base}
+ runner.runtime.registry.SetSubmitFreezer(
+ func(_ context.Context, submission tool.Submission) (string, error) {
+ return runner.soloFreezeWithContext(context.Background(), state, submission)
+ },
+ )
+
+ outcome := soloOutcome{}
+ const goal = "Create recovered.txt and submit the result."
+ if err := runner.soloConverse(context.Background(), goal, state, &outcome); err != nil {
+ t.Fatalf("solo recovery: %v", err)
+ }
+ if outcome.TerminalTrigger != "submitted" || state.candidate() == nil {
+ t.Fatalf("outcome=%#v candidate=%#v, want submitted", outcome, state.candidate())
+ }
+ if len(requestBodies) != 3 {
+ t.Fatalf("model requests=%d, want failed request plus one recovered tool cycle", len(requestBodies))
+ }
+ if got, err := os.ReadFile(filepath.Join(workspace, "recovered.txt")); err != nil ||
+ strings.TrimSpace(string(got)) != effect {
+ t.Fatalf("completed tool effect=%q err=%v", got, err)
+ }
+ second := string(requestBodies[1])
+ if !strings.Contains(second, goal) || !strings.Contains(second, soloRecoveryPrompt()) {
+ t.Fatalf("fresh request lost the task or recovery prompt: %s", second)
+ }
+ if strings.Contains(second, effect) {
+ t.Fatalf("failed assistant/tool payload leaked into fresh context: %s", second)
+ }
+ retries := 0
+ for _, event := range soloStageEvents(t, &events, "implement") {
+ if event["status"] == "transport-retry" {
+ retries++
+ }
+ }
+ if retries != 1 {
+ t.Fatalf("outer recovery turns=%d, want exactly 1", retries)
+ }
+}
diff --git a/internal/seniordev/app/runtime_test.go b/internal/seniordev/app/runtime_test.go
new file mode 100644
index 0000000000..2ecdc31df9
--- /dev/null
+++ b/internal/seniordev/app/runtime_test.go
@@ -0,0 +1,125 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+)
+
+type capturingBackend struct {
+ turns []turn
+}
+
+func (backend *capturingBackend) Run(_ context.Context, request turn) (turnResult, error) {
+ backend.turns = append(backend.turns, request)
+ return turnResult{}, nil
+}
+
+func TestTheBackendHasNoHTTPClientWallClockTimeout(t *testing.T) {
+ configured := newModelAPIBackend(testModelAPI, "")
+ if configured.client == nil {
+ t.Fatal("the backend has no HTTP client")
+ }
+ if configured.client.Timeout != 0 {
+ t.Fatalf("HTTP client timeout = %s, want disabled", configured.client.Timeout)
+ }
+}
+
+// fetch is the one door every model request leaves by, and it puts the model
+// API's token on whatever the request already said — a configured header
+// naming another credential included.
+func TestFetchCarriesTheModelAPIsTokenOverAnyOtherCredential(t *testing.T) {
+ var seen string
+ backend := newModelAPIBackend(testModelAPI, "")
+ backend.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ seen = request.Header.Get("Authorization")
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil
+ })}
+ request, err := http.NewRequest(http.MethodPost, testModelAPI.BaseURL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Authorization", "Bearer somebody-elses-key")
+ if _, err := backend.fetch(request); err != nil {
+ t.Fatal(err)
+ }
+ if seen != "Bearer "+testModelAPI.Token {
+ t.Fatalf("Authorization = %q, want the model API's token", seen)
+ }
+}
+
+func TestModelFilteringPreservesDisabledTools(t *testing.T) {
+ runtime := newRuntime(t.TempDir(), &capturingBackend{})
+ t.Cleanup(runtime.Close)
+ got := requestToolNames(runtime.definitionsFor(
+ "openrouter", "deepseek/deepseek-v4-pro", "coder", map[string]bool{"write": true},
+ ))
+ want := []string{"question", "bash", "read", "glob", "grep", "edit", "webfetch"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("tools = %v, want %v", got, want)
+ }
+}
+
+func TestDefinitionsForSeniorDevProviderIncludesWebSearch(t *testing.T) {
+ for _, name := range []string{
+ "SENIOR_DEV_EXPERIMENTAL", "SENIOR_DEV_ENABLE_EXA", "SENIOR_DEV_EXPERIMENTAL_EXA",
+ "SENIOR_DEV_ENABLE_PARALLEL", "SENIOR_DEV_EXPERIMENTAL_PARALLEL",
+ } {
+ t.Setenv(name, "")
+ }
+ runtime := newRuntime(t.TempDir(), &capturingBackend{})
+ t.Cleanup(runtime.Close)
+ got := requestToolNames(runtime.definitionsFor(
+ "senior-dev", "deepseek/deepseek-v4-pro", "coder", nil,
+ ))
+ if !slices.Contains(got, "websearch") {
+ t.Fatalf("senior-dev tools = %v", got)
+ }
+}
+
+func requestToolNames(definitions []steploop.ToolDefinition) []string {
+ names := make([]string, 0, len(definitions))
+ for _, definition := range definitions {
+ names = append(names, definition.Provider.Name)
+ }
+ return names
+}
+
+type turnCapturingBackend struct{ request turn }
+
+func (backend *turnCapturingBackend) Run(_ context.Context, request turn) (turnResult, error) {
+ backend.request = request
+ return turnResult{Text: "done"}, nil
+}
+
+func TestSoloTurnClearsInstructionClaimsAfterAssistant(t *testing.T) {
+ // The coding turn must receive the per-assistant instruction-claim cleanup
+ // hook. The solo pipeline has exactly one such turn, so if it omits the
+ // hook nothing else will supply it.
+ workspace := t.TempDir()
+ backend := &turnCapturingBackend{}
+ runner := &pipeline{
+ workspace: workspace,
+ runtime: newRuntime(workspace, backend),
+ pool: poolResolver{high: []string{"provider/model"}},
+ events: newEventWriter(discardWriter{}),
+ notes: discardWriter{},
+ }
+ if _, err := runner.soloTurn(context.Background(), "goal", "do the thing"); err != nil {
+ t.Fatal(err)
+ }
+ if backend.request.AfterAssistant == nil {
+ t.Fatal("the solo coding turn omitted AfterAssistant instruction cleanup")
+ }
+ if backend.request.Agent != "coder" {
+ t.Fatalf("solo turn agent = %q, want coder", backend.request.Agent)
+ }
+}
diff --git a/internal/seniordev/app/solo.go b/internal/seniordev/app/solo.go
new file mode 100644
index 0000000000..5d7884ed65
--- /dev/null
+++ b/internal/seniordev/app/solo.go
@@ -0,0 +1,771 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/project"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+// The solo pipeline is one continuous coding context surrounded by small
+// deterministic stages that do not think:
+//
+// intake (deterministic) -> ONE model context: explore, pin, implement,
+// conform, submit -> freeze (inside the submit tool) -> bounded verification
+// -> ship.
+//
+// The stages here own only what a model should not have to remember: writing
+// the spec down verbatim, capturing the candidate the instant it is submitted,
+// refusing to ship something worse than what was captured, and emitting one
+// terminal event that says why the run ended.
+
+// soloMaxNudges bounds the continuations offered to a run that stops without
+// submitting. Two is enough for "you forgot" and "here is what is actually
+// wrong"; a third is the model arguing with the runner.
+const (
+ soloMaxNudges = 2
+ soloMaxToolLeakCorrections = 2
+)
+
+// soloMaxRecoveryRetries bounds fresh turns offered after a transient
+// provider or transport failure. This is the only model-call retry in senior-dev:
+// re-entering the persisted session preserves completed work without silently
+// replaying a partial streamed response. Without it one dropped stream is a
+// lost run.
+const soloMaxRecoveryRetries = 3
+
+var errSoloLanding = errors.New("solo landing window reached")
+
+const (
+ soloLandingReserveCap = 12 * time.Minute
+ soloCheckTimeout = 2 * time.Minute
+ soloLandingTurnTimeout = 5 * time.Minute
+ soloFinalCheckTimeout = 3 * time.Minute
+)
+
+// soloOutcome is what the run decided, separated into what the model claimed
+// and what senior-dev independently observed. Keeping the two apart is the
+// point: a run that says "all tests pass" and did not run them must leave both
+// facts in the event stream rather than one reconciled story.
+type soloOutcome struct {
+ Status string
+ SubmissionReason string
+ Frozen *frozenCandidate
+ Verification *projectVerificationResult
+ Nudges int
+ LandingTurns int
+ TerminalTrigger string
+ RestoreSource string
+ LiveTree string
+ FinalTree string
+ SuiteDead bool
+
+ // TerminalData and TerminalReason are what the run has to say about how it
+ // ended. They travel to the CLI layer rather than being emitted here so the
+ // run emits exactly one terminal event; see soloTerminal.
+ TerminalData map[string]any
+ TerminalReason string
+}
+
+// frozenCandidate is the artifact of record. It is captured inside the submit
+// tool call, so by the time the model's next step runs this already exists and
+// nothing it does can reach what ships.
+type frozenCandidate struct {
+ // CommitSHA is a real commit object holding the whole tree (tracked and
+ // untracked, ignored files excluded), written through a temporary index so
+ // the working tree and the real index are never touched.
+ CommitSHA string
+ TreeSHA string
+ // PatchBytes and PatchFiles describe the diff against the run's base, and
+ // exist so a later restore decision can be logged in terms a human reads.
+ PatchBytes int
+ PatchFiles int
+ PatchSHA string
+ At time.Time
+
+ // The model's own claim, recorded verbatim and never reconciled with what
+ // senior-dev later observes.
+ Reason string
+ Evidence string
+ ChecklistSatisfied bool
+}
+
+func (candidate *frozenCandidate) describe() string {
+ if candidate == nil {
+ return "nothing frozen"
+ }
+ return fmt.Sprintf(
+ "%d bytes across %d file(s), tree %s",
+ candidate.PatchBytes, candidate.PatchFiles, shortSHA(candidate.TreeSHA),
+ )
+}
+
+func shortSHA(value string) string {
+ if len(value) > 12 {
+ return value[:12]
+ }
+ return value
+}
+
+// soloState carries the run's mutable freeze across the tool boundary. The
+// mutex exists because the submit tool executes on the step loop's goroutine
+// while the stage machine reads the result on its own.
+type soloState struct {
+ mu sync.Mutex
+ frozen *frozenCandidate
+ start *soloCheckpoint
+ coherent *soloCheckpoint
+ baseSHA string
+}
+
+type soloCheckpoint struct {
+ CommitSHA string
+ TreeSHA string
+ Source string
+}
+
+func (state *soloState) candidate() *frozenCandidate {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+ return state.frozen
+}
+
+func (state *soloState) freeze(candidate frozenCandidate) {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+ state.frozen = &candidate
+}
+
+func (state *soloState) checkpoints() (*soloCheckpoint, *soloCheckpoint) {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+ return state.start, state.coherent
+}
+
+func (state *soloState) setStart(checkpoint soloCheckpoint) {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+ state.start = &checkpoint
+}
+
+func (state *soloState) setCoherent(checkpoint soloCheckpoint) {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+ state.coherent = &checkpoint
+}
+
+// runSolo executes the whole simplified pipeline for one request.
+func (runner *pipeline) runSolo(
+ ctx context.Context, goal, baseSHA string,
+) (soloOutcome, error) {
+ state := &soloState{baseSHA: baseSHA}
+ outcome := soloOutcome{Status: "fail"}
+
+ if err := runner.soloIntake(goal); err != nil {
+ return outcome, err
+ }
+ if err := runner.soloCaptureStart(state); err != nil {
+ runner.note("[senior-dev] landing: could not capture the exact starting tree: " + err.Error() + "\n")
+ }
+ // Installed before the first turn so the tool is advertised, and left
+ // installed afterwards so a late submit during a nudge still freezes.
+ runner.runtime.registry.SetSubmitFreezer(
+ func(submitCtx context.Context, submission tool.Submission) (string, error) {
+ return runner.soloFreezeWithContext(submitCtx, state, submission)
+ },
+ )
+
+ // Ship runs on EVERY ending, the wall-clock kill included. Returning early
+ // on a converse error would skip both halves of stage 4 on the common
+ // ending of a full-budget run: no restore, so a run that submitted and then
+ // kept editing would ship the post-submission tree; and no terminal, so the
+ // run could not say whether it had submitted at all.
+ converseErr := runner.soloConverse(ctx, goal, state, &outcome)
+ runner.soloShip(ctx, state, &outcome, converseErr)
+ return outcome, converseErr
+}
+
+// soloIntake is stage 0. It writes the request down verbatim and nothing else.
+// The spec travels to every later stage as a file rather than as a paraphrase:
+// a restated request loses the exact identifiers the original names.
+func (runner *pipeline) soloIntake(goal string) error {
+ directory := filepath.Join(runner.workspace, ".senior-dev")
+ if err := os.MkdirAll(directory, 0o755); err != nil {
+ return fmt.Errorf("solo intake: %w", err)
+ }
+ specPath := filepath.Join(directory, "spec.md")
+ if err := os.WriteFile(specPath, []byte(goal), 0o644); err != nil {
+ return fmt.Errorf("solo intake: %w", err)
+ }
+ runner.events.stage("intake", "captured", map[string]any{
+ "spec_path": ".senior-dev/spec.md", "spec_bytes": len(goal),
+ })
+ runner.note("[senior-dev] intake: request captured verbatim at .senior-dev/spec.md\n")
+ return nil
+}
+
+// soloConverse runs the single coding context, plus bounded corrections for a
+// real stop or provider markup that failed to execute as a tool call.
+func (runner *pipeline) soloConverse(
+ ctx context.Context, goal string, state *soloState, outcome *soloOutcome,
+) error {
+ workCtx, cancel := runner.soloWorkContext(ctx)
+ defer cancel()
+ prompt, err := adaptSoloPrompt(
+ runner.recorder,
+ buildSoloPrompt(goal, runner.readPinnedCommand(), ".senior-dev/checklist.md"),
+ )
+ if err != nil {
+ return err
+ }
+ leakCorrections := 0
+ recoveryRetries := 0
+ for attempt := 0; ; {
+ runner.events.stage("implement", "running", map[string]any{"attempt": attempt})
+ response, err := runner.soloTurn(workCtx, goal, prompt)
+ if err != nil {
+ // A turn that errored may still have submitted before it died; the
+ // freeze is what decides, not the error.
+ if state.candidate() == nil {
+ if errors.Is(context.Cause(workCtx), errSoloLanding) {
+ outcome.TerminalTrigger = "landing-window"
+ return runner.soloLandingTurn(ctx, goal, state, outcome)
+ }
+ if retryInfo, transient := transientTurnError(err); transient &&
+ recoveryRetries < soloMaxRecoveryRetries && workCtx.Err() == nil {
+ recoveryRetries++
+ delay := soloRecoveryDelay(recoveryRetries)
+ data := map[string]any{
+ "attempt": attempt, "retry": recoveryRetries,
+ "max_retries": soloMaxRecoveryRetries,
+ "delay_ms": delay.Milliseconds(),
+ "class": retryInfo.Class,
+ "error": err.Error(),
+ }
+ if retryInfo.StatusCode != nil {
+ data["http_status"] = *retryInfo.StatusCode
+ }
+ if retryInfo.ProviderCode != "" {
+ data["provider_code"] = retryInfo.ProviderCode
+ }
+ runner.events.stage("implement", "transport-retry", data)
+ runner.note(fmt.Sprintf(
+ "[senior-dev] implement: turn died on a transient provider failure (%s); retry %d/%d\n",
+ retryInfo.Class, recoveryRetries, soloMaxRecoveryRetries,
+ ))
+ // A refused sleep means the work window closed during the
+ // wait; the next turn fails fast and lands above.
+ _ = runner.sleep(workCtx, delay)
+ prompt = soloRecoveryPrompt()
+ continue
+ }
+ // A dead turn is not a dead run: offer the landing turn so the
+ // tree that exists still gets independent verification and one
+ // bounded chance to submit. If nothing lands, the original
+ // error stands -- the trigger and the exit stay honest.
+ runner.events.stage("implement", "turn-error", map[string]any{
+ "attempt": attempt, "transport_retries": recoveryRetries,
+ "error": err.Error(),
+ })
+ if landErr := runner.soloLandingTurn(ctx, goal, state, outcome); landErr != nil {
+ runner.note("[senior-dev] implement: landing after a turn error also failed: " +
+ landErr.Error() + "\n")
+ }
+ if state.candidate() != nil {
+ return nil
+ }
+ outcome.TerminalTrigger = "turn-error"
+ return err
+ }
+ runner.note("[senior-dev] implement: turn ended with an error after submitting: " +
+ err.Error() + "\n")
+ }
+ if candidate := state.candidate(); candidate != nil {
+ outcome.TerminalTrigger = "submitted"
+ outcome.SubmissionReason = candidate.Reason
+ runner.events.stage("implement", "submitted", map[string]any{
+ "attempt": attempt, "reason": candidate.Reason,
+ "checklist_satisfied": candidate.ChecklistSatisfied,
+ })
+ return nil
+ }
+ if leakedToolCall(response) && leakCorrections < soloMaxToolLeakCorrections {
+ leakCorrections++
+ runner.events.stage("implement", "tool-call-leak", map[string]any{
+ "attempt": attempt, "correction": leakCorrections,
+ })
+ prompt = soloToolLeakPrompt()
+ continue
+ }
+ findings := runner.soloUnsubmittedFindings(state.baseSHA)
+ verification := runner.soloCheckUnsubmitted(
+ workCtx, state, soloCheckTimeout, "nudge",
+ )
+ if verification != nil {
+ outcome.Verification = verification
+ findings = append(findings, soloVerificationFindings(*verification)...)
+ }
+ if attempt >= soloMaxNudges || runner.budgetIsExhausted() {
+ if runner.budgetIsExhausted() {
+ outcome.TerminalTrigger = "budget"
+ } else {
+ outcome.TerminalTrigger = "nudge-cap"
+ }
+ runner.events.stage("implement", "unsubmitted", map[string]any{
+ "attempt": attempt, "budget_exhausted": runner.budgetIsExhausted(),
+ })
+ runner.note("[senior-dev] implement: run ended without a submission\n")
+ return nil
+ }
+ outcome.Nudges = attempt + 1
+ prompt = soloNudge(attempt+1, findings)
+ attempt++
+ }
+}
+
+func (runner *pipeline) soloTurn(ctx context.Context, goal, prompt string) (turnResult, error) {
+ if runner.turnForTest != nil {
+ return runner.turnForTest(ctx, goal, prompt)
+ }
+ markdown, ok := baked.GetBakedAgent("coder")
+ if !ok {
+ return turnResult{}, errors.New("solo: the coder agent is not available")
+ }
+ markdown, err := adaptCoderPrompt(runner.recorder, markdown)
+ if err != nil {
+ return turnResult{}, err
+ }
+ providerID, modelID := splitModelID(firstModel(runner.pool.high))
+ ctx = project.WithContext(ctx, project.InstanceContext{
+ Directory: runner.workspace, Worktree: runner.workspace,
+ Project: project.Info{Worktree: runner.workspace},
+ })
+ configured, err := runner.runtime.configureTurn(turn{
+ SessionID: runner.sessionID, SessionTitle: prefixUTF16(goal, 60),
+ Agent: "coder", AgentMarkdown: markdown, Workspace: runner.workspace,
+ ProviderID: providerID, ModelID: modelID, Prompt: prompt,
+ })
+ if err != nil {
+ return turnResult{}, err
+ }
+ configured.Tools = runner.runtime.definitionsFor(
+ configured.ProviderID, configured.ModelID, "coder", nil,
+ )
+ configured.Execute = runner.runtime.registry.Execute
+ configured.SystemInstructions = runner.runtime.registry.SystemInstructions(ctx)
+ configured.LoadInstructions = runner.runtime.registry.SystemInstructions
+ configured.AfterAssistant = runner.runtime.registry.ClearInstructionClaims
+ response, err := runner.runtime.runTurn(ctx, configured)
+ runner.runtime.addCost(response.CostUSD)
+ return response, err
+}
+
+type turnRetryInfo struct {
+ Class string
+ StatusCode *uint64
+ ProviderCode string
+}
+
+// transientTurnError recognizes only failures for which a fresh model turn is
+// useful. Structured provider data wins; the string table is a fallback for
+// transports that expose only Error(). Context endings and permanent account,
+// request, and configuration failures never retry.
+func transientTurnError(err error) (turnRetryInfo, bool) {
+ if err == nil || errors.Is(err, context.Canceled) ||
+ errors.Is(err, context.DeadlineExceeded) {
+ return turnRetryInfo{}, false
+ }
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return turnRetryInfo{Class: "unexpected-eof"}, true
+ }
+
+ text := strings.ToLower(err.Error())
+ var failure *modelTurnError
+ if errors.As(err, &failure) {
+ text += " " + strings.ToLower(failure.responseBody)
+ if permanentProviderLimit(text) || failure.kind == msgmodel.ErrNameContextOverflow {
+ return turnRetryInfo{}, false
+ }
+ providerCode := providerErrorType(failure.responseBody)
+ info := turnRetryInfo{StatusCode: failure.statusCode, ProviderCode: providerCode}
+ if failure.statusCode != nil {
+ switch status := *failure.statusCode; {
+ case status == 408:
+ info.Class = "request-timeout"
+ case status == 409:
+ info.Class = "provider-conflict"
+ case status == 429:
+ info.Class = "rate-limit"
+ case status >= 500:
+ info.Class = "provider-5xx"
+ default:
+ return turnRetryInfo{}, false
+ }
+ return info, true
+ }
+ if providerCode == "provider_unavailable" {
+ info.Class = "provider-unavailable"
+ return info, true
+ }
+ if failure.retryable {
+ info.Class = "provider-retryable"
+ return info, true
+ }
+ }
+
+ for _, transport := range []struct{ needle, class string }{
+ {"sse read timed out", "sse-read-timeout"},
+ {"the operation timed out", "request-timeout"},
+ {"unexpected eof", "unexpected-eof"},
+ {"connection reset", "connection-reset"},
+ {"broken pipe", "broken-pipe"},
+ {"connection refused", "connection-refused"},
+ {"tls handshake timeout", "tls-handshake-timeout"},
+ {"server closed idle connection", "idle-connection-closed"},
+ {"http2: server sent goaway", "http2-goaway"},
+ {"i/o timeout", "io-timeout"},
+ {"network error", "network-error"},
+ {"connection error", "connection-error"},
+ {"connection lost", "connection-lost"},
+ {"other side closed", "connection-closed"},
+ {"fetch failed", "fetch-failed"},
+ {"getaddrinfo", "dns-failure"},
+ {"enotfound", "dns-failure"},
+ {"eai_again", "dns-failure"},
+ {"upstream connect", "upstream-connect"},
+ {"reset before headers", "connection-reset"},
+ {"socket hang up", "socket-hangup"},
+ {"socket connection was closed", "socket-closed"},
+ {"stream ended before", "stream-ended"},
+ {"ended without", "stream-ended"},
+ {"provider_unavailable", "provider-unavailable"},
+ {"provider unavailable", "provider-unavailable"},
+ {"service unavailable", "provider-unavailable"},
+ {"overloaded", "provider-overloaded"},
+ {"rate limit", "rate-limit"},
+ {"too many requests", "rate-limit"},
+ {"retry after", "provider-retry-requested"},
+ {"you can retry your request", "provider-retry-requested"},
+ {"please retry", "provider-retry-requested"},
+ {"try your request again", "provider-retry-requested"},
+ } {
+ if strings.Contains(text, transport.needle) {
+ return turnRetryInfo{Class: transport.class}, true
+ }
+ }
+ return turnRetryInfo{}, false
+}
+
+func permanentProviderLimit(text string) bool {
+ for _, phrase := range []string{
+ "gousagelimiterror", "freeusagelimiterror", "monthly usage limit reached",
+ "available balance", "insufficient_quota", "out of budget", "quota exceeded",
+ "billing",
+ } {
+ if strings.Contains(text, phrase) {
+ return true
+ }
+ }
+ return false
+}
+
+func providerErrorType(body string) string {
+ if body == "" {
+ return ""
+ }
+ type metadata struct {
+ ErrorType string `json:"error_type"`
+ }
+ var value struct {
+ Metadata metadata `json:"metadata"`
+ Error *struct {
+ Metadata metadata `json:"metadata"`
+ } `json:"error"`
+ }
+ if json.Unmarshal([]byte(body), &value) != nil {
+ return ""
+ }
+ if value.Metadata.ErrorType != "" {
+ return strings.ToLower(value.Metadata.ErrorType)
+ }
+ if value.Error != nil {
+ return strings.ToLower(value.Error.Metadata.ErrorType)
+ }
+ return ""
+}
+
+// soloRecoveryDelay escalates 5s, 15s, 45s: long enough for a proxy or
+// provider blip to clear, short against a landing reserve measured in minutes.
+func soloRecoveryDelay(retry int) time.Duration {
+ delay := 5 * time.Second
+ for i := 1; i < retry; i++ {
+ delay *= 3
+ }
+ return delay
+}
+
+func soloRecoveryPrompt() string {
+ return "The previous turn failed and is not in context. Its completed tool effects remain " +
+ "in the workspace. Inspect the diff and continue the original request."
+}
+
+func leakedToolCall(result turnResult) bool {
+ for _, part := range result.Parts {
+ if part.Type == "tool" {
+ return false
+ }
+ }
+ text := strings.ToLower(result.Text)
+ if !strings.Contains(text, "dsml") {
+ return false
+ }
+ for _, toolName := range []string{"bash", "read", "write", "edit", "grep", "glob"} {
+ if strings.Contains(text, toolName) {
+ return true
+ }
+ }
+ return false
+}
+
+// soloUnsubmittedFindings is what senior-dev can say about the tree without
+// asking the model. A nudge carrying facts beats a nudge carrying
+// encouragement: the usual cause of a missing submit is not sloth but a model
+// that believes it is finished and is wrong about one mechanical thing.
+func (runner *pipeline) soloUnsubmittedFindings(baseSHA string) []string {
+ var findings []string
+ // Whether the tree differs from the base is the first thing to say, and it
+ // is not the same question as whether git status is clean: a run can have
+ // committed everything and still have changed nothing that matters.
+ if change, err := runner.soloTreeChange(baseSHA); err == nil {
+ if !change.changed {
+ findings = append(findings,
+ "the tree is byte-identical to the starting commit — "+
+ "nothing has been implemented, so there is nothing to submit")
+ } else {
+ findings = append(findings, fmt.Sprintf(
+ "the tree differs from the starting commit in %d file(s)", change.files))
+ }
+ }
+ // Only the git recorder has an index to be unclean, and only it can act on
+ // the advice. Under --in-place nothing commits, so the finding would send
+ // the model after a step it cannot take.
+ if git, ok := runner.recorder.(*gitRecorder); ok {
+ findings = append(findings, git.statusFindings()...)
+ }
+ if pinned := runner.readPinnedCommand(); pinned == "" {
+ findings = append(findings,
+ "no pinned command was recorded in .senior-dev/pinned.txt — "+
+ "you have no reproducible way to show the work passes")
+ } else {
+ findings = append(findings, "your pinned command is: "+pinned)
+ }
+ if _, err := os.Stat(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil {
+ findings = append(findings,
+ "no .senior-dev/checklist.md exists — the request's own requirements were never enumerated")
+ }
+ return findings
+}
+
+func plural(count int, singular, many string) string {
+ if count == 1 {
+ return singular
+ }
+ return many
+}
+
+func (runner *pipeline) readPinnedCommand() string {
+ data, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "pinned.txt"))
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)[0])
+}
+
+// soloFreezeWithContext captures the candidate. It runs inside the submit tool
+// call, and its refusals are the cheapest place in the whole run to catch an
+// empty or debris-laden patch: the same defects found after the run cost
+// everything.
+func (runner *pipeline) soloFreezeWithContext(
+ ctx context.Context, state *soloState, submission tool.Submission,
+) (string, error) {
+ existing := state.candidate()
+ if existing != nil {
+ return "", runner.soloRefuseSubmit("already-submitted", fmt.Errorf(
+ "this run already submitted at %s (%s); the frozen tree is the answer and cannot be replaced",
+ existing.At.Format(time.RFC3339), existing.describe(),
+ ))
+ }
+ change, err := runner.soloTreeChange(state.baseSHA)
+ if err != nil {
+ return "", runner.soloRefuseSubmit("capture-error",
+ fmt.Errorf("could not capture the tree: %w", err))
+ }
+ if !change.changed {
+ return "", runner.soloRefuseSubmit("empty-tree", errors.New(
+ "the working tree is identical to the base commit — there is nothing to submit"))
+ }
+ checklist := runner.soloChecklistState()
+ if !checklist.present {
+ return "", runner.soloRefuseSubmit("no-checklist", errors.New(
+ "no .senior-dev/checklist.md exists — the request's own requirements were never "+
+ "enumerated, so there is nothing to have checked the work against. "+
+ "Write it, verify against it, then submit again"))
+ }
+ treeSHA, patch := change.treeSHA, change.patch
+ commitSHA, err := runner.soloCommitTree(treeSHA, submission.Reason)
+ if err != nil {
+ return "", runner.soloRefuseSubmit("record-error",
+ fmt.Errorf("could not record the tree: %w", err))
+ }
+ digest := sha256.Sum256([]byte(patch))
+ candidate := frozenCandidate{
+ CommitSHA: commitSHA, TreeSHA: treeSHA,
+ PatchBytes: len(patch), PatchFiles: change.files,
+ PatchSHA: fmt.Sprintf("%x", digest[:8]), At: runner.now(),
+ Reason: submission.Reason, Evidence: submission.Evidence,
+ ChecklistSatisfied: submission.ChecklistSatisfied,
+ }
+ state.freeze(candidate)
+ // checklist_satisfied is the model's CLAIM; checklist_items/checklist_ticked
+ // are what the file actually says. They are recorded side by side and never
+ // reconciled: models routinely claim satisfaction without ticking a box, so
+ // gating on the ticks would refuse most submissions. The one refusal with
+ // evidence behind it is no checklist at all.
+ runner.events.stage(frozenStage, frozenStatus, map[string]any{
+ "reason": submission.Reason, "evidence": submission.Evidence,
+ "checklist_satisfied": submission.ChecklistSatisfied,
+ "checklist_items": checklist.items,
+ "checklist_ticked": checklist.ticked,
+ "patch_bytes": candidate.PatchBytes, "patch_files": candidate.PatchFiles,
+ "tree_sha": treeSHA, "commit_sha": commitSHA,
+ })
+ runner.note("[senior-dev] submit: candidate frozen — " + candidate.describe() + "\n")
+ return candidate.describe(), nil
+}
+
+// soloRefuseSubmit makes a submit refusal countable. A refusal that travels
+// only as tool-call error text is reconstructable from the message stream by
+// callID and from nothing else; a refusal the event stream cannot count cannot
+// be diagnosed.
+func (runner *pipeline) soloRefuseSubmit(class string, err error) error {
+ runner.events.stage("submit", "refused", map[string]any{
+ "reason_class": class, "detail": err.Error(),
+ })
+ return err
+}
+
+// soloCommitTree writes a commit object for an already-written tree without
+// moving HEAD, the index, or the working tree. The commit exists so the
+// candidate can be restored later by a single git command even if the run dies
+// between here and finalize.
+func (runner *pipeline) soloCommitTree(treeSHA, reason string) (string, error) {
+ message := "senior-dev: submitted candidate"
+ if trimmed := strings.TrimSpace(reason); trimmed != "" {
+ message += "\n\n" + trimmed
+ }
+ commitSHA, err := runner.soloRecordTree(treeSHA, message)
+ if err != nil {
+ return "", err
+ }
+ if err := runner.recorder.Publish(soloFrozenRef, commitSHA); err != nil {
+ // The ref is a convenience for a restore from outside this process; losing it does not
+ // invalidate the freeze, which is already a durable commit object.
+ runner.note("[senior-dev] submit: could not update " + soloFrozenRef + ": " + err.Error() + "\n")
+ }
+ return commitSHA, nil
+}
+
+func (runner *pipeline) soloRecordTree(treeSHA, message string) (string, error) {
+ return runner.recorder.Record(treeSHA, message)
+}
+
+// soloFrozenRef makes the frozen candidate reachable from outside this process,
+// so a hard kill between submit and finalize still has something to restore.
+const soloFrozenRef = "refs/senior-dev/submitted"
+
+// soloTreeChange describes the whole working tree against the run's base.
+type soloTreeChange struct {
+ treeSHA string
+ patch string
+ files int
+ changed bool
+}
+
+// seniorDevArtifactPathspecs exclude the run artifacts senior-dev itself writes into
+// the workspace -- the session database, spec.md, the checklist, the pinned
+// command -- from the answer. Without the exclusion, submit would accept a
+// tree whose only change is senior-dev's own bookkeeping and the run would ship
+// nothing while reporting success.
+var seniorDevArtifactPathspecs = []string{
+ ":(exclude).senior-dev",
+}
+
+// soloChecklistState reports what .senior-dev/checklist.md actually contains, as
+// distinct from what the model says about it. Both markdown task-list forms are
+// counted ("- [ ] x" and "[ ] x"), because the prompt shows the bare form and
+// models usually write the dashed one.
+type soloChecklist struct {
+ present bool
+ items int
+ ticked int
+}
+
+var soloChecklistItem = regexp.MustCompile(`^\s*(?:[-*]\s*)?\[([ xX])\]\s`)
+
+func (runner *pipeline) soloChecklistState() soloChecklist {
+ raw, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md"))
+ if err != nil {
+ return soloChecklist{}
+ }
+ state := soloChecklist{present: true}
+ for _, line := range strings.Split(string(raw), "\n") {
+ match := soloChecklistItem.FindStringSubmatch(line)
+ if match == nil {
+ continue
+ }
+ state.items++
+ if match[1] != " " {
+ state.ticked++
+ }
+ }
+ return state
+}
+
+// soloTreeChange compares the workspace against the base commit's tree,
+// ignoring senior-dev's own artifacts.
+//
+// It deliberately does not use `git diff ` against the working copy,
+// which reports only tracked changes. A run whose whole deliverable is a new
+// file -- which is most of them -- produces an empty `git diff` while having
+// changed everything that matters, so diffing that way would refuse exactly
+// the submissions worth accepting. currentTreeSHA stages everything through a
+// temporary index, so comparing against that tree sees new files the way a
+// diff of the final tree will.
+func (runner *pipeline) soloTreeChange(baseSHA string) (soloTreeChange, error) {
+ return runner.recorder.Change(baseSHA)
+}
+
+func nonEmptyLines(value string) []string {
+ var lines []string
+ for _, line := range strings.Split(value, "\n") {
+ if strings.TrimSpace(line) != "" {
+ lines = append(lines, line)
+ }
+ }
+ return lines
+}
diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go
new file mode 100644
index 0000000000..dfc08e183f
--- /dev/null
+++ b/internal/seniordev/app/solo_finalize.go
@@ -0,0 +1,236 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "errors"
+ "time"
+)
+
+// soloLandingReserve sizes the landing window: two fifteenths of the wall
+// budget, at least 45 seconds and at most soloLandingReserveCap, but never more
+// than a quarter of the run so short runs keep most of their time for work.
+func soloLandingReserve(limit time.Duration) time.Duration {
+ if limit <= 0 {
+ return 0
+ }
+ reserve := limit * 2 / 15
+ if reserve < 45*time.Second {
+ reserve = 45 * time.Second
+ }
+ if reserve > soloLandingReserveCap {
+ reserve = soloLandingReserveCap
+ }
+ if maximum := limit / 4; reserve > maximum {
+ reserve = maximum
+ }
+ return reserve
+}
+
+func (runner *pipeline) soloWorkContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ if runner.budget.MaxWallMS == nil {
+ return context.WithCancel(ctx)
+ }
+ limit := time.Duration(*runner.budget.MaxWallMS * float64(time.Millisecond))
+ deadline := runner.wallStart.Add(limit - soloLandingReserve(limit))
+ if parent, ok := ctx.Deadline(); ok && !parent.After(deadline) {
+ return context.WithCancel(ctx)
+ }
+ return context.WithDeadlineCause(ctx, deadline, errSoloLanding)
+}
+
+func (runner *pipeline) soloCaptureStart(state *soloState) error {
+ treeSHA, err := runner.currentTreeSHA()
+ if err != nil {
+ return err
+ }
+ commitSHA, err := runner.soloRecordTree(treeSHA, "senior-dev: exact starting tree")
+ if err != nil {
+ return err
+ }
+ // The starting tree is also reachable by name, so the compaction
+ // changed-files record (engine_compaction.go) and anything outside this
+ // process can diff against it without knowing the commit.
+ if err := runner.recorder.Publish(soloStartRef, commitSHA); err != nil {
+ runner.note("[senior-dev] start: could not update " + soloStartRef + ": " + err.Error() + "\n")
+ }
+ state.setStart(soloCheckpoint{
+ CommitSHA: commitSHA, TreeSHA: treeSHA, Source: "starting-tree",
+ })
+ runner.events.stage("landing", "start-captured", map[string]any{"tree_sha": treeSHA})
+ return nil
+}
+
+func (runner *pipeline) soloLandingTurn(
+ ctx context.Context, goal string, state *soloState, outcome *soloOutcome,
+) error {
+ findings := runner.soloUnsubmittedFindings(state.baseSHA)
+ verification := runner.soloCheckUnsubmitted(
+ ctx, state, soloCheckTimeout, "landing",
+ )
+ if verification != nil {
+ outcome.Verification = verification
+ findings = append(findings, soloVerificationFindings(*verification)...)
+ }
+ if runner.budgetIsExhausted() || ctx.Err() != nil {
+ return nil
+ }
+ outcome.LandingTurns++
+ runner.events.stage("landing", "repair-turn", map[string]any{
+ "timeout_ms": soloLandingTurnTimeout.Milliseconds(),
+ })
+ landingCtx, cancel := context.WithTimeout(ctx, soloLandingTurnTimeout)
+ defer cancel()
+ _, err := runner.soloTurn(landingCtx, goal, soloLandingPrompt(findings))
+ if candidate := state.candidate(); candidate != nil {
+ outcome.TerminalTrigger = "submitted-during-landing"
+ outcome.SubmissionReason = candidate.Reason
+ return nil
+ }
+ if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
+ !errors.Is(err, context.Canceled) {
+ outcome.TerminalTrigger = "landing-turn-error"
+ return err
+ }
+ return nil
+}
+
+// soloCheckUnsubmitted executes the standard entrypoints itself. It never
+// trusts the model's shell pipeline exit status: `cargo build | tail` reports
+// success while the build fails.
+func (runner *pipeline) soloCheckUnsubmitted(
+ ctx context.Context,
+ state *soloState,
+ maximum time.Duration,
+ phase string,
+) *projectVerificationResult {
+ change, err := runner.soloTreeChange(state.baseSHA)
+ if err != nil || !change.changed {
+ return nil
+ }
+ if runner.lastVerify != nil && runner.lastVerifyTreeSHA == change.treeSHA {
+ remembered := *runner.lastVerify
+ return &remembered
+ }
+ if ctx.Err() != nil || maximum <= 0 {
+ return nil
+ }
+ checkCtx, cancel := context.WithTimeout(ctx, maximum)
+ defer cancel()
+ verify := runner.verifyForTest
+ if verify == nil {
+ verify = runner.runProjectVerification
+ }
+ result := verify(checkCtx)
+ runner.rememberVerifiedTree(result)
+ command, dead := verificationShowsDeadTree(result)
+ _, unsafe := verificationShowsSafetyRegression(result)
+ runner.events.stage("landing", "checked", map[string]any{
+ "phase": phase, "tree_sha": change.treeSHA,
+ "commands": len(result.Commands), "timed_out": result.TimedOut,
+ "failing": countFailingEntrypoints(result), "suite_dead": dead,
+ "safety_regression": unsafe,
+ "dead_command": command,
+ })
+ if !result.TimedOut && len(result.Commands) > 0 && !unsafe {
+ runner.soloCaptureCoherent(state, change.treeSHA, "coherent-checkpoint")
+ }
+ return &result
+}
+
+// soloCaptureCoherent records a tree whose verification completed without a
+// build, parse or suite-start regression, as the checkpoint an unsubmitted
+// dead tree is restored to.
+func (runner *pipeline) soloCaptureCoherent(state *soloState, treeSHA, source string) {
+ commitSHA, err := runner.soloRecordTree(treeSHA, "senior-dev: coherent "+source+" checkpoint")
+ if err != nil {
+ return
+ }
+ state.setCoherent(soloCheckpoint{CommitSHA: commitSHA, TreeSHA: treeSHA, Source: source})
+}
+
+func soloVerificationFindings(result projectVerificationResult) []string {
+ if command, dead := verificationShowsDeadTree(result); dead {
+ return []string{
+ "independent verification proves the suite cannot start: `" + command + "`",
+ "the exact failure is: " + verificationFailureSummary(
+ result, countFailingEntrypoints(result),
+ ),
+ }
+ }
+ if result.TimedOut {
+ return []string{"independent verification did not complete; do not claim it passed"}
+ }
+ if result.Failed != nil {
+ return []string{"independent verification failed: " + verificationFailureSummary(
+ result, countFailingEntrypoints(result),
+ )}
+ }
+ if len(result.Commands) > 0 {
+ return []string{"independent verification passed; finish the checklist and call submit"}
+ }
+ return nil
+}
+
+func (runner *pipeline) soloFinalizeUnsubmitted(
+ ctx context.Context, state *soloState, outcome *soloOutcome,
+) {
+ if live, err := runner.currentTreeSHA(); err == nil {
+ outcome.LiveTree = live
+ }
+ verification := runner.soloCheckUnsubmitted(
+ ctx, state, soloFinalCheckTimeout, "final",
+ )
+ if verification != nil {
+ outcome.Verification = verification
+ _, outcome.SuiteDead = verificationShowsDeadTree(*verification)
+ }
+ if outcome.SuiteDead {
+ // A tree whose suite cannot start is restored to the strongest
+ // earlier checkpoint: the latest coherent one, else the starting
+ // tree, else the base commit.
+ start, coherent := state.checkpoints()
+ var target *soloCheckpoint
+ if coherent != nil && coherent.TreeSHA != outcome.LiveTree {
+ target = coherent
+ }
+ if target == nil && start != nil && start.TreeSHA != outcome.LiveTree {
+ target = start
+ }
+ if target == nil && state.baseSHA != "" {
+ if tree, ok := runner.recorder.BaseTree(state.baseSHA); ok {
+ target = &soloCheckpoint{
+ CommitSHA: state.baseSHA, TreeSHA: tree, Source: "starting-commit",
+ }
+ }
+ }
+ if target != nil {
+ if err := runner.soloRestoreCheckpoint(*target); err != nil {
+ runner.events.stage("landing", "restore-failed", map[string]any{
+ "source": target.Source, "error": err.Error(),
+ })
+ } else {
+ outcome.RestoreSource = target.Source
+ runner.events.stage("landing", "restored", map[string]any{
+ "source": target.Source, "from_tree": outcome.LiveTree,
+ "to_tree": target.TreeSHA,
+ })
+ }
+ }
+ }
+ if final, err := runner.currentTreeSHA(); err == nil {
+ outcome.FinalTree = final
+ }
+}
+
+func (runner *pipeline) soloRestoreCheckpoint(checkpoint soloCheckpoint) error {
+ return runner.soloRestoreTree(checkpoint.CommitSHA, checkpoint.TreeSHA)
+}
+
+// soloRestoreTree makes the working tree the recorded one and proves it did.
+// How that is achieved is the recorder's business; both implementations
+// re-identify the result rather than trusting the operation.
+func (runner *pipeline) soloRestoreTree(commitSHA, wantTree string) error {
+ return runner.recorder.Restore(commitSHA, wantTree)
+}
diff --git a/internal/seniordev/app/solo_prompt.go b/internal/seniordev/app/solo_prompt.go
new file mode 100644
index 0000000000..3b200e4057
--- /dev/null
+++ b/internal/seniordev/app/solo_prompt.go
@@ -0,0 +1,164 @@
+//go:build !windows
+
+package app
+
+import (
+ "fmt"
+ "strings"
+)
+
+// These strings are the run instruction: the first user message of a solo run,
+// and the bounded continuations sent when a turn ends without a submission.
+//
+// They carry mechanics only -- the files the run uses, what senior-dev does on
+// its own, and what ends the run. They do not tell the model when to edit, how
+// much to explore, or how fast to move; those are its decisions, and a sentence
+// spent on them is a sentence competing with the repository it is about to
+// read. Keep every claim here true of this binary: a prompt that describes
+// behaviour the code does not have is worse than a prompt that omits it.
+
+// soloSystemPreamble frames the run instruction and nothing else. The detail is
+// in the sections, and a long preamble is what a model under context pressure
+// drops first.
+const soloSystemPreamble = `You are implementing one change in this repository, by yourself, in one context.
+
+What follows is how this run works: the files it uses, what senior-dev does,
+and what ends it.`
+
+// soloIntakeSection names the specification file. Intake writes .senior-dev/spec.md
+// verbatim and compaction re-pins it from disk, so it is the one copy of the
+// request that outlives the conversation.
+const soloIntakeSection = `## The specification
+
+.senior-dev/spec.md holds the request verbatim. It is the specification, and it is
+re-pinned from that file whenever this context is compacted.`
+
+// soloExploreSection names the pinned-command file. readPinnedCommand takes the
+// first line of .senior-dev/pinned.txt and quotes it back in the nudge findings and
+// in a later run's header, so the file has a reader even when the model forgets
+// what it wrote there.
+const soloExploreSection = `## The verification command
+
+Write the build or test command you verify with to .senior-dev/pinned.txt, on one
+line. senior-dev reads that first line and quotes it back to you if this run
+needs a continuation.`
+
+// soloImplementSection states what the workspace is and what leaves it. The
+// exclusion list is seniorDevArtifactPathspecs: if the two disagree, submit
+// accepts a tree whose only content is senior-dev's own bookkeeping.
+const soloImplementSection = `## The workspace
+
+The workspace is a git repository. Your tools are the ones declared with this
+turn: a shell, file reading, editing, search, web access, and submit.
+
+.senior-dev/ and git-ignored paths are excluded from the answer. Everything else in
+the working tree, committed or not, is part of what you submit.
+
+Only the workspace is handed back. The file tools refuse to write outside it, and
+anything a shell command changes outside it is lost: do the work here.`
+
+// soloConformanceSection names the checklist file. soloFreeze refuses a
+// submission when it is missing and records its item and tick counts when it is
+// present, and soloChecklistItem matches both "[ ] x" and "- [ ] x".
+const soloConformanceSection = `## The checklist
+
+Write .senior-dev/checklist.md: one line per thing the request requires, each
+starting "[ ] ", ticked to "[x]" when the code satisfies it. submit refuses if
+this file does not exist, and records its item and tick counts.`
+
+// soloSubmitSection is the completion protocol. It has to agree with the system
+// prompt that the run ends by calling submit and by nothing else: when the two
+// disagreed, the model followed the system prompt.
+const soloSubmitSection = `## Ending the run
+
+The run ends when you call the submit tool. Nothing else ends it: no status
+tag, no report, no summary.
+
+submit takes a reason, the evidence you verified with, and checklist_satisfied.
+It refuses, naming the cause, when the tree is unchanged from the starting
+commit, when .senior-dev/checklist.md does not exist, when reason or evidence is
+empty, or when this run already submitted. A refusal does not end the run.
+
+An accepted submit freezes the tree at that instant. senior-dev then runs this
+project's own build and test entrypoints itself and records what they report;
+that cannot change what ships, and neither can anything you edit afterwards — a
+tree that moves after a submission is reverted to the frozen one.
+
+A run that never submits is recorded as unsubmitted and leaves behind the tree
+as it stands, except that a tree whose suite cannot start is restored to an
+earlier one.`
+
+// buildSoloPrompt assembles the run instruction. The request is repeated at the
+// top verbatim: it travels through no paraphrase on its way to the model.
+func buildSoloPrompt(goal string, pinned string, checklistPath string) string {
+ sections := []string{
+ soloSystemPreamble,
+ soloIntakeSection,
+ soloExploreSection,
+ soloImplementSection,
+ soloConformanceSection,
+ soloSubmitSection,
+ }
+ body := strings.Join(sections, "\n\n")
+ header := "# The request\n\n" + strings.TrimSpace(goal) +
+ "\n\n(The same text is in .senior-dev/spec.md, which is the specification.)\n\n"
+ if strings.TrimSpace(pinned) != "" {
+ header += fmt.Sprintf(
+ ".senior-dev/pinned.txt already contains: %s\n\n", strings.TrimSpace(pinned),
+ )
+ }
+ if checklistPath != "" {
+ header += "Write your checklist to " + checklistPath + ".\n\n"
+ }
+ return header + "# How this run works\n\n" + body
+}
+
+// soloNudge is the bounded continuation for a run that stopped talking without
+// submitting. It carries the facts senior-dev can see for itself rather than
+// encouragement, and says what an unsubmitted ending actually does.
+func soloNudge(attempt int, findings []string) string {
+ return "You stopped without calling submit, so no submission has been captured.\n\n" +
+ soloUnsubmittedBody(findings, attempt >= soloMaxNudges)
+}
+
+// soloLandingPrompt is the one bounded turn offered after the open work window
+// closes or a turn dies. No model turn follows it, and it is reached without
+// the model having stopped, so it opens on its own terms rather than soloNudge's.
+func soloLandingPrompt(findings []string) string {
+ return "This is the last turn of this run, and it is time-bounded. " +
+ "No submission has been captured yet.\n\n" +
+ soloUnsubmittedBody(findings, true)
+}
+
+// soloUnsubmittedBody is what every continuation says: the facts senior-dev
+// observed, the one ending there is, and what an unsubmitted run actually leaves
+// behind (which is the live tree, not nothing).
+func soloUnsubmittedBody(findings []string, last bool) string {
+ var builder strings.Builder
+ if len(findings) > 0 {
+ builder.WriteString("senior-dev checked the tree itself and found:\n\n")
+ for _, finding := range findings {
+ builder.WriteString(" - " + finding + "\n")
+ }
+ builder.WriteString("\n")
+ }
+ builder.WriteString(
+ "The run ends when you call submit and by nothing else. A run that never " +
+ "calls it is recorded as unsubmitted and leaves behind the tree as it stands.",
+ )
+ if last {
+ builder.WriteString(
+ "\n\nThis is the last prompt you will get. senior-dev then checks that " +
+ "final tree itself, and restores an earlier tree only if the suite cannot start.",
+ )
+ }
+ return builder.String()
+}
+
+// soloToolLeakPrompt answers a response that carried provider markup as text
+// instead of executing it. leakedToolCall detects it, and the correction is
+// capped at soloMaxToolLeakCorrections.
+func soloToolLeakPrompt() string {
+ return "Your last response contained DSML tool-call markup as plain text, so no tool ran. " +
+ "Make the intended call as a real tool call; this correction is offered at most twice."
+}
diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go
new file mode 100644
index 0000000000..140ed9349c
--- /dev/null
+++ b/internal/seniordev/app/solo_ship.go
@@ -0,0 +1,237 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "fmt"
+)
+
+// soloShip is stage 4's tail: bounded verification, then one decision about
+// what submitted or unsubmitted tree the run leaves behind.
+//
+// The rule this file exists to enforce is that the run never ships a tree
+// worse than the one it submitted. The comparison is against the frozen
+// candidate, not against liveness.
+func (runner *pipeline) soloShip(
+ ctx context.Context, state *soloState, outcome *soloOutcome, converseErr error,
+) {
+ candidate := state.candidate()
+ if candidate == nil {
+ // No model-declared candidate exists, so independently check the exact
+ // live tree. Ordinary test failures and incomplete observations keep the
+ // benefit of the doubt. A build/parse regression restores the strongest
+ // earlier green or coherent checkpoint available.
+ outcome.Status = "unsubmitted"
+ runner.soloFinalizeUnsubmitted(ctx, state, outcome)
+ reason := "no submission: the run stopped without calling submit"
+ if converseErr != nil {
+ reason += " (" + converseErr.Error() + ")"
+ }
+ if outcome.RestoreSource != "" {
+ reason += "; the live tree's suite could not start and it was restored from " + outcome.RestoreSource
+ }
+ runner.soloTerminal(outcome, reason)
+ return
+ }
+ outcome.Frozen = candidate
+
+ // Verification needs a live context and time to run. When the run is out of
+ // wall budget or its context is already cancelled, there is neither: the
+ // only honest thing left is to restore the frozen candidate and say that
+ // nothing checked it. Attempting it anyway would record an instantly-failed
+ // build as evidence against the candidate, which would be a false red.
+ if reason, blocked := runner.verificationUnaffordable(ctx); blocked {
+ outcome.Status = "pass-unverified"
+ runner.soloTerminal(outcome, fmt.Sprintf(
+ "%s; shipping the submitted candidate, which nothing checked: %s",
+ reason, candidate.describe(),
+ ))
+ runner.soloRestoreIfDiverged(state, outcome)
+ return
+ }
+
+ // The candidate is already captured, so verification cannot change what
+ // ships -- only what the run says about it. That is the whole point of
+ // doing it after the freeze rather than before.
+ verification := runner.runProjectVerification(ctx)
+ outcome.Verification = &verification
+ failing := countFailingEntrypoints(verification)
+
+ switch {
+ case verification.TimedOut && ctx.Err() != nil:
+ // The run was stopped while the check ran. What ships is the frozen
+ // candidate, and what the run can truthfully say is that it submitted
+ // and nothing finished checking it.
+ outcome.Status = "pass-unverified"
+ runner.soloTerminal(outcome, fmt.Sprintf(
+ "the run was stopped while the project's build and tests ran; "+
+ "shipping the submitted candidate, which nothing finished checking: %s",
+ candidate.describe(),
+ ))
+ case verification.TimedOut:
+ // A hung entrypoint is an incomplete observation, not a verdict. The
+ // candidate stands.
+ outcome.Status = "pass-unverified"
+ runner.soloTerminal(outcome, fmt.Sprintf(
+ "verification did not complete (an entrypoint hung); shipping the submitted candidate: %s",
+ candidate.describe(),
+ ))
+ case verification.Failed == nil:
+ // Failed, not the failing-command count, is the verdict. An expected
+ // build or test entrypoint that could not be DISCOVERED sets Failed
+ // while recording no command at all, so counting commands would call a
+ // project whose suite was never found -- the vacuous-green shape -- a
+ // verified pass.
+ outcome.Status = "pass"
+ runner.soloTerminal(outcome, fmt.Sprintf(
+ "submitted, and its build and tests passed: %s (%s)", candidate.describe(), candidate.Reason,
+ ))
+ default:
+ // The candidate does not verify. It is still what ships: it is the only
+ // tree this run ever declared finished, and there is no better one --
+ // the alternative is the unverified live tree, which by construction is
+ // the same tree. What changes is the honesty of the terminal.
+ outcome.Status = "fail"
+ runner.soloTerminal(outcome, fmt.Sprintf(
+ "submitted candidate failed verification (%s); "+
+ "shipping it anyway as the run's own answer: %s",
+ verificationFailureSummary(verification, failing), candidate.describe(),
+ ))
+ }
+ runner.soloRestoreIfDiverged(state, outcome)
+}
+
+// verificationUnaffordable reports whether post-submit verification can still
+// be run at all, and why not. Both conditions are ordinary endings rather than
+// faults: a run is expected to use its whole budget, and the context is
+// cancelled when the wall deadline passes.
+func (runner *pipeline) verificationUnaffordable(ctx context.Context) (string, bool) {
+ if err := ctx.Err(); err != nil {
+ return "the run's context ended before verification could start", true
+ }
+ if exhausted, reason := runner.budgetExhausted(); exhausted {
+ if reason == "" {
+ reason = "the run budget was exhausted"
+ }
+ return reason, true
+ }
+ return "", false
+}
+
+// soloRestoreIfDiverged puts the frozen candidate back if anything moved the
+// tree after submission. Nothing in the pipeline should -- the submit tool
+// tells the model to stop, and no stage after it edits -- but "should not" is
+// not a guarantee, and the check is two git commands.
+func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOutcome) {
+ candidate := state.candidate()
+ if candidate == nil {
+ return
+ }
+ current, err := runner.currentTreeSHA()
+ if err != nil {
+ runner.note("[senior-dev] ship: could not compare the tree to the frozen candidate: " +
+ err.Error() + "\n")
+ return
+ }
+ if current == candidate.TreeSHA {
+ runner.events.stage("ship", "unchanged", map[string]any{
+ "tree_sha": current, "reason": "tree is identical to the frozen candidate",
+ })
+ return
+ }
+ // Diverged. Restoring is unconditional: post-submission edits are not part
+ // of the answer by definition, whether they look like improvements or not.
+ // soloRestoreTree rather than a bare checkout: a file ADDED after submit is
+ // tracked by eager-commit and would survive an overlay checkout, shipping a
+ // tree that silently differs from the frozen candidate it claims to be.
+ if err := runner.soloRestoreTree(candidate.CommitSHA, candidate.TreeSHA); err != nil {
+ runner.events.stage("ship", "restore-failed", map[string]any{
+ "error": err.Error(), "commit_sha": candidate.CommitSHA,
+ })
+ runner.note("[senior-dev] ship: RESTORE FAILED, shipping the diverged tree: " + err.Error() + "\n")
+ return
+ }
+ runner.events.stage("ship", "restored", map[string]any{
+ "from_tree": current, "to_tree": candidate.TreeSHA,
+ "commit_sha": candidate.CommitSHA,
+ "reason": "the tree changed after submission; the frozen candidate is the answer",
+ })
+ runner.note(fmt.Sprintf(
+ "[senior-dev] ship: tree changed after submission (%s != %s) — restored the frozen candidate\n",
+ shortSHA(current), shortSHA(candidate.TreeSHA),
+ ))
+ // outcome.Status is deliberately untouched: the verdict was about the
+ // candidate, and the candidate is what is now on disk again.
+}
+
+// soloTerminal records the reason the run ended and the evidence behind it.
+// "Why did it exit?" must be answerable from the event stream without a log.
+func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) {
+ data := map[string]any{
+ "status": outcome.Status, "reason": reason,
+ "submitted": outcome.Frozen != nil, "nudges": outcome.Nudges,
+ "landing_turns": outcome.LandingTurns, "terminal_trigger": outcome.TerminalTrigger,
+ }
+ if outcome.LiveTree != "" {
+ data["live_tree"] = outcome.LiveTree
+ }
+ if outcome.FinalTree != "" {
+ data["final_tree"] = outcome.FinalTree
+ }
+ if outcome.RestoreSource != "" {
+ data["restore_source"] = outcome.RestoreSource
+ }
+ if outcome.SuiteDead {
+ data["suite_dead"] = true
+ }
+ if candidate := outcome.Frozen; candidate != nil {
+ data["submission_reason"] = candidate.Reason
+ data["submission_evidence"] = candidate.Evidence
+ data["checklist_satisfied"] = candidate.ChecklistSatisfied
+ data["patch_bytes"] = candidate.PatchBytes
+ data["patch_files"] = candidate.PatchFiles
+ data["frozen_tree"] = candidate.TreeSHA
+ data["frozen_commit"] = candidate.CommitSHA
+ }
+ if verification := outcome.Verification; verification != nil {
+ failing := countFailingEntrypoints(*verification)
+ data["verification_failing"] = failing
+ data["verification_timed_out"] = verification.TimedOut
+ data["verification_commands"] = len(verification.Commands)
+ // Why the check failed, when it did, in the same words the run's own
+ // reason uses. A failure with no failing command (an expected build or
+ // test entrypoint nobody could find) is otherwise indistinguishable
+ // from a pass in the counts alone.
+ if verification.Failed != nil {
+ data["verification_failure"] = verificationFailureSummary(*verification, failing)
+ }
+ }
+ // Deliberately NOT emitted here. There is exactly one terminal event per
+ // run and the CLI layer emits it (persistTerminalResult), because that is
+ // the one place reached by every ending including a crash before ship. This
+ // hands it the payload; emitting a second "terminal" from here would produce
+ // two events with one name.
+ outcome.TerminalData = data
+ outcome.TerminalReason = reason
+ runner.note("[senior-dev] terminal: " + reason + "\n")
+}
+
+// missingEntrypointFailure reports whether verification failed because an
+// expected build or test entrypoint could not be discovered at all, rather
+// than because a command it ran came back non-zero. recordMissingEntrypoint
+// stamps that source string; it is the only producer of it.
+func missingEntrypointFailure(result projectVerificationResult) bool {
+ return result.Failed != nil &&
+ result.Failed.Source == "manifest/CI/documentation discovery"
+}
+
+func verificationFailureSummary(result projectVerificationResult, failing int) string {
+ if missingEntrypointFailure(result) {
+ return "no " + string(result.Failed.Kind) + " entrypoint could be discovered"
+ }
+ if failing == 1 {
+ return "1 failing entrypoint"
+ }
+ return fmt.Sprintf("%d failing entrypoints", failing)
+}
diff --git a/internal/seniordev/app/solo_test.go b/internal/seniordev/app/solo_test.go
new file mode 100644
index 0000000000..3abea22c98
--- /dev/null
+++ b/internal/seniordev/app/solo_test.go
@@ -0,0 +1,978 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification"
+ "github.com/Agent-Field/codeaf/internal/seniordev/tool"
+)
+
+// soloEvents decodes the NDJSON the pipeline emitted so a test can assert on
+// what the artifact will actually contain, rather than on internal state a
+// reader of the run will never see.
+func soloEvents(t *testing.T, raw *bytes.Buffer) []map[string]any {
+ t.Helper()
+ var events []map[string]any
+ for _, line := range strings.Split(strings.TrimSpace(raw.String()), "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal([]byte(line), &decoded); err != nil {
+ continue
+ }
+ events = append(events, decoded)
+ }
+ return events
+}
+
+func soloStageEvents(t *testing.T, raw *bytes.Buffer, stage string) []map[string]any {
+ t.Helper()
+ var matched []map[string]any
+ for _, event := range soloEvents(t, raw) {
+ if name, _ := event["stage"].(string); name != stage {
+ continue
+ }
+ // The stage name and status live on the envelope; everything the test
+ // asserts about lives in data. Flatten so a test reads one map.
+ flattened := map[string]any{"status": event["status"]}
+ if data, ok := event["data"].(map[string]any); ok {
+ for key, value := range data {
+ flattened[key] = value
+ }
+ }
+ matched = append(matched, flattened)
+ }
+ return matched
+}
+
+// soloPipeline builds a pipeline over a real git workspace with a stub backend,
+// plus the submit freezer wired exactly as runSolo wires it.
+func soloPipeline(t *testing.T) (*pipeline, *soloState, string, *bytes.Buffer) {
+ t.Helper()
+ workspace, base := guardWorkspace(t)
+ var events bytes.Buffer
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(&events), Notes: io.Discard,
+ Sleep: func(context.Context, time.Duration) error { return nil },
+ })
+ t.Cleanup(runner.runtime.Close)
+ // A run that reaches submit has written one; tests of submit start there.
+ // TestSubmitRefusedWithoutAChecklist removes it to exercise the gate.
+ if err := writeFile(filepath.Join(workspace, ".senior-dev", "checklist.md"),
+ "- [ ] the thing the request asked for\n"); err != nil {
+ t.Fatal(err)
+ }
+ state := &soloState{baseSHA: base}
+ runner.runtime.registry.SetSubmitFreezer(
+ func(_ context.Context, submission tool.Submission) (string, error) {
+ return runner.soloFreezeWithContext(context.Background(), state, submission)
+ },
+ )
+ return runner, state, base, &events
+}
+
+func soloSubmission(reason string) tool.Submission {
+ return tool.Submission{
+ Reason: reason, Evidence: "make test: exit 0, 41 passed",
+ ChecklistSatisfied: true, SessionID: "ses_solo",
+ }
+}
+
+func TestSoloIntakeWritesTheRequestVerbatim(t *testing.T) {
+ // The spec reaches every later stage as a file, never as a paraphrase. A
+ // restated request drops the exact identifiers the original names, and the
+ // run then ships code that does the right thing under names the request
+ // never used.
+ runner, _, _, events := soloPipeline(t)
+ goal := "Add `expandShorthand(property, value)` to lib/shorthand.js.\n" +
+ "It MUST be named exactly that. Trailing spaces matter: \n\ttabs too."
+ if err := runner.soloIntake(goal); err != nil {
+ t.Fatal(err)
+ }
+ written, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "spec.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(written) != goal {
+ t.Fatalf("spec.md was not byte-identical to the request:\n got %q\nwant %q", written, goal)
+ }
+ captured := soloStageEvents(t, events, "intake")
+ if len(captured) != 1 || captured[0]["status"] != "captured" {
+ t.Fatalf("intake events = %#v", captured)
+ }
+}
+
+func TestSubmitFreezesTheTreeAndRefusesASecondSubmission(t *testing.T) {
+ // One submission per run. A second one is not a mistake to absorb quietly:
+ // the model is telling us it thinks it can still change the answer, and it
+ // needs to be told plainly that it cannot.
+ runner, state, _, events := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+
+ description, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("feature implemented"))
+ if err != nil {
+ t.Fatalf("first submit refused: %v", err)
+ }
+ if !strings.Contains(description, "file") {
+ t.Fatalf("freeze description = %q", description)
+ }
+ candidate := state.candidate()
+ if candidate == nil || candidate.TreeSHA == "" || candidate.CommitSHA == "" {
+ t.Fatalf("candidate = %#v", candidate)
+ }
+ if candidate.Reason != "feature implemented" || !candidate.ChecklistSatisfied {
+ t.Fatalf("the model's claim was not recorded verbatim: %#v", candidate)
+ }
+
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("actually, this version")); err == nil {
+ t.Fatal("a second submission was accepted")
+ } else if !strings.Contains(err.Error(), "cannot be replaced") {
+ t.Fatalf("second-submit refusal = %v", err)
+ }
+ if state.candidate().Reason != "feature implemented" {
+ t.Fatal("the second submission overwrote the frozen candidate")
+ }
+
+ var frozen []map[string]any
+ for _, event := range soloStageEvents(t, events, "submit") {
+ if event["status"] == "frozen" {
+ frozen = append(frozen, event)
+ }
+ }
+ if len(frozen) != 1 {
+ t.Fatalf("frozen events = %d, want exactly 1", len(frozen))
+ }
+ if frozen[0]["evidence"] != "make test: exit 0, 41 passed" {
+ t.Fatalf("submit event lost the evidence: %#v", frozen[0])
+ }
+}
+
+func TestSubmitOnAnUnchangedTreeIsRefused(t *testing.T) {
+ // A patch containing only probe scripts and a patch containing only
+ // documentation are the same failure -- declaring done on a tree that
+ // implements nothing -- and submit is the cheapest place in the run to
+ // catch it.
+ runner, state, _, _ := soloPipeline(t)
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err == nil {
+ t.Fatal("submitting an unchanged tree was accepted")
+ } else if !strings.Contains(err.Error(), "identical to the base commit") {
+ t.Fatalf("refusal = %v", err)
+ }
+ if state.candidate() != nil {
+ t.Fatal("a refused submit still froze something")
+ }
+}
+
+func TestSubmitIgnoresSeniorDevsOwnArtifactsWhenDecidingSomethingChanged(t *testing.T) {
+ // senior-dev writes .senior-dev/ into the workspace it works in: the session
+ // database, spec.md, the pinned command. None of that is part of the answer,
+ // so a tree whose only content is senior-dev's own bookkeeping is an empty
+ // patch.
+ //
+ // Deciding "did anything change" from the raw tree would therefore accept
+ // exactly the submissions worth refusing: the run reports success, and
+ // ships nothing.
+ runner, state, _, _ := soloPipeline(t)
+ if err := runner.soloIntake("write a feature"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(runner.workspace, ".senior-dev", "pinned.txt"), []byte("make test\n"), 0o644,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err == nil {
+ t.Fatal("a tree containing only senior-dev's own artifacts was accepted as a submission")
+ } else if !strings.Contains(err.Error(), "identical to the base commit") {
+ t.Fatalf("refusal = %v", err)
+ }
+
+ // One real file makes it a real submission, and it is the only one counted.
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil {
+ t.Fatalf("a genuine one-file submission was refused: %v", err)
+ }
+ if files := state.candidate().PatchFiles; files != 1 {
+ t.Fatalf("PatchFiles = %d, want 1 — senior-dev's own artifacts are being counted", files)
+ }
+}
+
+func TestShipRestoresTheFrozenCandidateWhenTheTreeMovesAfterSubmission(t *testing.T) {
+ // Nothing in the pipeline edits after submit, but "nothing should" is not a
+ // guarantee, and a run that keeps editing after submitting can leave a tree
+ // whose build no longer passes. Post-submission edits are not part of the
+ // answer whether they look like improvements or not.
+ runner, state, _, events := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "the submitted version\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil {
+ t.Fatal(err)
+ }
+ frozenTree := state.candidate().TreeSHA
+
+ // Something touches the tree after the freeze.
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "a later, unblessed edit\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(runner.workspace, "debris.tmp"), "scratch\n"); err != nil {
+ t.Fatal(err)
+ }
+
+ outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()}
+ runner.soloRestoreIfDiverged(state, outcome)
+
+ content, err := os.ReadFile(filepath.Join(runner.workspace, "feature.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != "the submitted version\n" {
+ t.Fatalf("shipped file = %q, want the submitted version", content)
+ }
+ restored := soloStageEvents(t, events, "ship")
+ if len(restored) != 1 || restored[0]["status"] != "restored" {
+ t.Fatalf("ship events = %#v", restored)
+ }
+ if restored[0]["to_tree"] != frozenTree {
+ t.Fatalf("restored to %v, want the frozen tree %v", restored[0]["to_tree"], frozenTree)
+ }
+ if reason, _ := restored[0]["reason"].(string); !strings.Contains(reason, "after submission") {
+ t.Fatalf("restore event does not say why: %#v", restored[0])
+ }
+}
+
+func TestShipLeavesAnUntouchedTreeAlone(t *testing.T) {
+ // The complement: when nothing moved, ship must not run a checkout at all.
+ // A restore that fires on every run is a restore nobody will believe when
+ // it matters.
+ runner, state, _, events := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil {
+ t.Fatal(err)
+ }
+ runner.soloRestoreIfDiverged(state, &soloOutcome{Status: "pass", Frozen: state.candidate()})
+ shipped := soloStageEvents(t, events, "ship")
+ if len(shipped) != 1 || shipped[0]["status"] != "unchanged" {
+ t.Fatalf("ship events = %#v, want a single unchanged", shipped)
+ }
+}
+
+func TestTerminalEventIsEmittedOnceAndSaysWhyTheRunEnded(t *testing.T) {
+ // "Why did it exit?" is a question the event stream has to answer without
+ // a log. A decision that reports only through a log line is lost with the
+ // log.
+ runner, state, _, events := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("auto-toc rule implemented and green")); err != nil {
+ t.Fatal(err)
+ }
+ outcome := &soloOutcome{Status: "pass", Frozen: state.candidate(), Nudges: 1}
+ runner.soloTerminal(outcome, "submitted and verified")
+
+ // Asserted on the payload that reaches the run's terminal event
+ // (type=="terminal"), not on a stage event named "terminal": a terminal
+ // that carries nothing but a cost cannot answer "did it submit?".
+ _ = events
+ data := outcome.TerminalData
+ if data == nil {
+ t.Fatal("no terminal payload was recorded")
+ }
+ for _, key := range []string{
+ "reason", "submission_reason", "submission_evidence",
+ "checklist_satisfied", "patch_bytes", "frozen_tree", "nudges",
+ } {
+ if _, ok := data[key]; !ok {
+ t.Fatalf("terminal payload is missing %q: %#v", key, data)
+ }
+ }
+ if data["status"] != "pass" || data["submitted"] != true {
+ t.Fatalf("terminal payload = %#v", data)
+ }
+}
+
+func TestAnUnsubmittedRunSaysSoRatherThanClaimingAnAttempt(t *testing.T) {
+ // A run that never submitted did not finish. Reporting it as anything else
+ // turns "done" into "whatever the tree looked like when the budget
+ // expired".
+ runner, state, _, events := soloPipeline(t)
+ outcome := &soloOutcome{Status: "fail"}
+ runner.soloShip(context.Background(), state, outcome, nil)
+
+ if outcome.Status != "unsubmitted" {
+ t.Fatalf("status = %q, want unsubmitted", outcome.Status)
+ }
+ _ = events
+ data := outcome.TerminalData
+ if data == nil || data["submitted"] != false {
+ t.Fatalf("terminal payload = %#v", data)
+ }
+ if reason, _ := data["reason"].(string); !strings.Contains(reason, "without calling submit") {
+ t.Fatalf("terminal reason = %q", reason)
+ }
+}
+
+func soloTestVerification(exit int, dead bool) projectVerificationResult {
+ entrypoint := fullverification.Entrypoint{
+ Kind: fullverification.KindBuild, Command: "cargo build", Source: "Cargo.toml",
+ }
+ command := map[string]any{
+ "cmd": entrypoint.Command, "exit": float64(exit),
+ "tail": "error: could not compile `widget`",
+ }
+ if dead {
+ command["suite_dead"] = true
+ }
+ result := projectVerificationResult{Commands: []any{command}}
+ if exit != 0 {
+ result.Failed = &entrypoint
+ result.Failure = "cargo build failed"
+ }
+ return result
+}
+
+func TestUnsubmittedDeadTreeRestoresExactStartingTree(t *testing.T) {
+ runner, state, _, _ := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "preexisting.txt"), "keep me\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := runner.soloCaptureStart(state); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(runner.workspace, "broken.rs"), "does not compile\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner.verifyForTest = func(context.Context) projectVerificationResult {
+ return soloTestVerification(101, true)
+ }
+ outcome := &soloOutcome{Status: "fail", TerminalTrigger: "nudge-cap"}
+ runner.soloShip(context.Background(), state, outcome, nil)
+
+ if outcome.RestoreSource != "starting-tree" || !outcome.SuiteDead {
+ t.Fatalf("finalization = %#v", outcome)
+ }
+ if _, err := os.Stat(filepath.Join(runner.workspace, "broken.rs")); !os.IsNotExist(err) {
+ t.Fatalf("suite-dead file survived restore: %v", err)
+ }
+ kept, err := os.ReadFile(filepath.Join(runner.workspace, "preexisting.txt"))
+ if err != nil || string(kept) != "keep me\n" {
+ t.Fatalf("starting untracked file was not restored exactly: %q, %v", kept, err)
+ }
+}
+
+func TestUnsubmittedOrdinaryFailureKeepsLiveTree(t *testing.T) {
+ runner, state, _, _ := soloPipeline(t)
+ if err := runner.soloCaptureStart(state); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(runner.workspace, "solution.js"), "working solution\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner.verifyForTest = func(context.Context) projectVerificationResult {
+ result := soloTestVerification(1, false)
+ result.Commands[0].(map[string]any)["tail"] = "1 failed; 126 passed"
+ return result
+ }
+ outcome := &soloOutcome{Status: "fail", TerminalTrigger: "landing-window"}
+ runner.soloShip(context.Background(), state, outcome, nil)
+
+ if outcome.RestoreSource != "" || outcome.SuiteDead {
+ t.Fatalf("ordinary failure triggered rollback: %#v", outcome)
+ }
+ if got, err := os.ReadFile(filepath.Join(runner.workspace, "solution.js")); err != nil || string(got) != "working solution\n" {
+ t.Fatalf("live solution was not preserved: %q, %v", got, err)
+ }
+}
+
+func TestUnsubmittedDeadTreeRestoresLatestCoherentCheckpoint(t *testing.T) {
+ runner, state, _, _ := soloPipeline(t)
+ if err := runner.soloCaptureStart(state); err != nil {
+ t.Fatal(err)
+ }
+ feature := filepath.Join(runner.workspace, "feature.rs")
+ if err := writeFile(feature, "partial but coherent\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner.verifyForTest = func(context.Context) projectVerificationResult {
+ return soloTestVerification(1, false)
+ }
+ if result := runner.soloCheckUnsubmitted(
+ context.Background(), state, time.Second, "nudge",
+ ); result == nil {
+ t.Fatal("coherent checkpoint verification did not run")
+ }
+ if err := writeFile(feature, "mid-edit and suite-dead\n"); err != nil {
+ t.Fatal(err)
+ }
+ runner.verifyForTest = func(context.Context) projectVerificationResult {
+ return soloTestVerification(101, true)
+ }
+ outcome := &soloOutcome{Status: "fail", TerminalTrigger: "nudge-cap"}
+ runner.soloShip(context.Background(), state, outcome, nil)
+
+ if outcome.RestoreSource != "coherent-checkpoint" {
+ t.Fatalf("restore source = %q, want coherent checkpoint", outcome.RestoreSource)
+ }
+ if got, err := os.ReadFile(feature); err != nil || string(got) != "partial but coherent\n" {
+ t.Fatalf("coherent work was not restored: %q, %v", got, err)
+ }
+}
+
+func TestSoloLandingReserveScalesWithoutConsumingShortRuns(t *testing.T) {
+ cases := map[time.Duration]time.Duration{
+ 90 * time.Minute: 12 * time.Minute,
+ 7 * time.Minute: 56 * time.Second,
+ 1 * time.Minute: 15 * time.Second,
+ }
+ for limit, want := range cases {
+ if got := soloLandingReserve(limit); got != want {
+ t.Errorf("reserve(%s) = %s, want %s", limit, got, want)
+ }
+ }
+}
+
+func TestUnsubmittedFindingsCarryFactsNotEncouragement(t *testing.T) {
+ // The nudge exists to correct a specific mechanical belief, so it has to
+ // carry what senior-dev can see for itself. On a clean tree the first
+ // thing it must say is that there is no change at all -- the failure mode
+ // where a run believes it implemented something it never wrote.
+ runner, _, base, _ := soloPipeline(t)
+ // The finding under test is about a MISSING checklist, so remove the one
+ // soloPipeline provides.
+ if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil {
+ t.Fatal(err)
+ }
+ findings := runner.soloUnsubmittedFindings(base)
+ joined := strings.Join(findings, "\n")
+ if !strings.Contains(joined, "nothing has been implemented") {
+ t.Fatalf("findings on an empty tree = %#v", findings)
+ }
+ if !strings.Contains(joined, "no pinned command") {
+ t.Fatalf("findings do not mention the missing pinned command: %#v", findings)
+ }
+ if !strings.Contains(joined, "checklist.md") {
+ t.Fatalf("findings do not mention the missing checklist: %#v", findings)
+ }
+
+ if err := os.WriteFile(
+ filepath.Join(runner.workspace, ".senior-dev", "pinned.txt"),
+ []byte("pnpm exec jest auto-toc\n"), 0o644,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if pinned := runner.readPinnedCommand(); pinned != "pnpm exec jest auto-toc" {
+ t.Fatalf("pinned command = %q", pinned)
+ }
+}
+
+func TestSoloPromptCarriesTheMechanicsSeniorDevReads(t *testing.T) {
+ // The run instruction carries mechanics only: the three files senior-dev
+ // reads or refuses over, and the one way the run ends. A silent edit that
+ // drops one of them breaks a code path no build catches -- submit refuses
+ // without the checklist, and readPinnedCommand has no other writer.
+ prompt := buildSoloPrompt("Add expandShorthand to lib/shorthand.js", "", ".senior-dev/checklist.md")
+ for _, mechanic := range []string{
+ "Add expandShorthand to lib/shorthand.js", // the request, verbatim and first
+ ".senior-dev/spec.md", // the spec is a file, not a memory
+ ".senior-dev/pinned.txt", // readPinnedCommand's only writer
+ ".senior-dev/checklist.md", // soloFreeze refuses without it
+ "[ ] ", // the form soloChecklistItem counts
+ "submit", // the run's only ending
+ "Nothing else ends it", // and it is the only one
+ "refuses", // a refusal is not the end of the run
+ } {
+ if !strings.Contains(prompt, mechanic) {
+ t.Errorf("solo prompt no longer contains %q", mechanic)
+ }
+ }
+ if strings.Contains(prompt, "acceptance contract") ||
+ strings.Contains(prompt, "contract.json") {
+ t.Error("the prompt names an acceptance contract, which nothing in the run reads")
+ }
+ // The prompt states mechanics, not history and not pacing: no anecdote
+ // from a past run, no fraction of wall to aim for, and no instruction about
+ // when to start editing.
+ for _, regression := range []string{
+ "Finishing early", "winning", "A run that shipped",
+ "A run that implemented", "read-only", "Do not skip ahead",
+ } {
+ if strings.Contains(prompt, regression) {
+ t.Errorf("solo prompt carries history or pacing advice again: %q", regression)
+ }
+ }
+}
+
+func TestNudgeEscalatesOnTheLastAttempt(t *testing.T) {
+ // The bound has to be visible to the model. A nudge loop the model cannot
+ // see the end of is one it can keep deferring.
+ early := soloNudge(1, []string{"git status is clean"})
+ last := soloNudge(soloMaxNudges, []string{"git status is clean"})
+ if strings.Contains(early, "last prompt") {
+ t.Error("the first nudge already claims to be the last")
+ }
+ if !strings.Contains(last, "last prompt") {
+ t.Error("the final nudge does not say it is final")
+ }
+ if !strings.Contains(early, "git status is clean") {
+ t.Error("the nudge dropped the findings")
+ }
+ // What an ignored nudge actually does, stated as solo_ship.go does it: the
+ // run is recorded unsubmitted and the live tree is what it leaves behind.
+ // It is NOT true that such a run ships nothing.
+ if !strings.Contains(early, "unsubmitted") {
+ t.Error("the nudge does not say what happens if it is ignored")
+ }
+ if strings.Contains(early, "ships nothing") {
+ t.Error("the nudge claims an unsubmitted run ships nothing, which it does not")
+ }
+}
+
+// TestSubmitRefusedWithoutAChecklist pins the one checklist refusal there is.
+// A run that writes no checklist at all has nothing to check its work against
+// and has not finished; such runs ship mid-edit trees that break pre-existing
+// tests.
+func TestSubmitRefusedWithoutAChecklist(t *testing.T) {
+ runner, state, _, _ := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil {
+ t.Fatal(err)
+ }
+ _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done"))
+ if err == nil {
+ t.Fatal("submit was accepted with no checklist")
+ }
+ if !strings.Contains(err.Error(), "checklist.md") {
+ t.Fatalf("refusal = %q, want it to name the missing checklist", err)
+ }
+ if state.candidate() != nil {
+ t.Fatal("a refused submission froze a candidate")
+ }
+}
+
+// TestSubmitCountsTicksButDoesNotGateOnThem is the other half. Models
+// routinely claim checklist_satisfied without ticking a box, so gating on
+// ticks would refuse most submissions, verified passes included. The ticks are
+// COUNTED and recorded next to the model's claim, and the gap between them is
+// left visible rather than resolved into a refusal.
+func TestSubmitCountsTicksButDoesNotGateOnThem(t *testing.T) {
+ runner, state, _, events := soloPipeline(t)
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ checklist := "# Checklist\n\n- [ ] one\n- [x] two\n[ ] three\nnot an item\n"
+ if err := writeFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md"), checklist); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil {
+ t.Fatalf("submit refused despite a present checklist: %v", err)
+ }
+ submitted := soloStageEvents(t, events, "submit")
+ if len(submitted) != 1 {
+ t.Fatalf("submit events = %d, want 1", len(submitted))
+ }
+ if got := submitted[0]["checklist_items"]; got != float64(3) && got != 3 {
+ t.Fatalf("checklist_items = %v (%T), want 3", got, got)
+ }
+ if got := submitted[0]["checklist_ticked"]; got != float64(1) && got != 1 {
+ t.Fatalf("checklist_ticked = %v (%T), want 1", got, got)
+ }
+ // The claim and the observation are both present and both unreconciled.
+ if _, ok := submitted[0]["checklist_satisfied"]; !ok {
+ t.Fatal("the model's own claim is no longer recorded alongside the count")
+ }
+}
+
+// clearGitIdentity strips every source of a git committer identity for the
+// duration of the test: the repo config, the global and system files, and the
+// GIT_* / EMAIL environment. A container image that ships no git config is a
+// normal case, not an exotic one.
+func clearGitIdentity(t *testing.T, workspace string) {
+ t.Helper()
+ for _, key := range []string{"user.name", "user.email"} {
+ // --unset returns 5 when the key is already absent; that is fine.
+ _ = gitRun(workspace, "config", "--unset", key)
+ }
+ // Unsetting is not enough on a developer machine: git happily invents
+ // user@hostname when the hostname has a domain, and only refuses when it
+ // cannot (a container yields an identity like 'root@0123abcd.(none)').
+ // useConfigOnly makes that refusal unconditional, so the test reproduces
+ // the container's condition on any host.
+ if err := gitRun(workspace, "config", "user.useConfigOnly", "true"); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{
+ "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL",
+ "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL",
+ } {
+ t.Setenv(name, "") // registers restoration
+ if err := os.Unsetenv(name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
+ t.Setenv("HOME", t.TempDir())
+}
+
+// The freeze must not depend on the container having a git identity.
+//
+// If workspaceGit shelled plain `git` while eager-commit went through
+// attribution.GitArgv, every wip(edit) commit would work and the one commit
+// that decides what ships would fail with
+//
+// could not record the tree: git commit-tree …: exit status 128:
+// Author identity unknown … unable to auto-detect email address
+//
+// leaving the model to run `git config user.email …` and submit again.
+func TestFreezeRecordsTheTreeWithoutAConfiguredGitIdentity(t *testing.T) {
+ workspace, _ := guardWorkspace(t)
+ clearGitIdentity(t, workspace)
+
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(discardWriter{}), Notes: discardWriter{},
+ })
+ t.Cleanup(runner.runtime.Close)
+
+ if err := writeFile(filepath.Join(workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ treeSHA, err := runner.currentTreeSHA()
+ if err != nil {
+ t.Fatalf("capturing the tree failed: %v", err)
+ }
+
+ // Guard the guard: if this environment can still resolve an identity, the
+ // assertion below would pass whether or not the fix is present.
+ bare := exec.Command("git", "commit-tree", treeSHA, "-m", "identity probe")
+ bare.Dir = workspace
+ if out, bareErr := bare.CombinedOutput(); bareErr == nil {
+ t.Fatalf("test environment still has a git identity, so it cannot detect the defect: %s", out)
+ }
+
+ commitSHA, err := runner.soloCommitTree(treeSHA, "the candidate")
+ if err != nil {
+ t.Fatalf("soloCommitTree needs a configured git identity: %v", err)
+ }
+ if commitSHA == "" {
+ t.Fatal("soloCommitTree returned an empty commit")
+ }
+ recorded, err := runner.recorder.(*gitRecorder).git("rev-parse", commitSHA+"^{tree}")
+ if err != nil || recorded != treeSHA {
+ t.Fatalf("frozen commit points at %q (err %v); want tree %q", recorded, err, treeSHA)
+ }
+}
+
+func TestCurrentTreeSHAIncludesTrackedIgnoredFiles(t *testing.T) {
+ workspace, _ := guardWorkspace(t)
+ // currentTreeSHA only needs a workspace. Avoid starting the durable runtime,
+ // which intentionally creates untracked .senior-dev state unrelated to this
+ // exact-index regression.
+ runner := &pipeline{workspace: workspace, recorder: newGitRecorder(workspace, func(string) {})}
+ if err := writeFile(filepath.Join(workspace, ".gitignore"), "tracked-ignored.txt\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, "tracked-ignored.txt"), "base\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "add", ".gitignore"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "add", "-f", "tracked-ignored.txt"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "commit", "-m", "track an ignored file"); err != nil {
+ t.Fatal(err)
+ }
+
+ headTree, err := runner.recorder.(*gitRecorder).git("rev-parse", "HEAD^{tree}")
+ if err != nil {
+ t.Fatal(err)
+ }
+ unchanged, err := runner.currentTreeSHA()
+ if err != nil || unchanged != headTree {
+ t.Fatalf("unchanged tree = %q (err %v), want HEAD tree %q", unchanged, err, headTree)
+ }
+ if err := writeFile(filepath.Join(workspace, "tracked-ignored.txt"), "modified\n"); err != nil {
+ t.Fatal(err)
+ }
+ modified, err := runner.currentTreeSHA()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if modified == headTree {
+ t.Fatal("tracked-but-ignored modification was absent from the captured tree")
+ }
+ content, err := runner.recorder.(*gitRecorder).git("show", modified+":tracked-ignored.txt")
+ if err != nil || content != "modified" {
+ t.Fatalf("captured ignored file = %q (err %v), want modified", content, err)
+ }
+}
+
+// A restore must remove files ADDED after the checkpoint, not only revert
+// edits. Overlay checkout cannot: every model-written file is tracked by
+// eager-commit, so probe debris (a scratch test file the model added)
+// survives `checkout --force -- .` + `clean -fd`, and the "restored"
+// tree is not the checkpoint. The runs whose debris breaks the suite are
+// exactly the ones that need this to work.
+func TestRestoreRemovesFilesAddedAfterTheCheckpoint(t *testing.T) {
+ workspace, _ := guardWorkspace(t)
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(discardWriter{}), Notes: discardWriter{},
+ })
+ t.Cleanup(runner.runtime.Close)
+
+ if err := writeFile(filepath.Join(workspace, "feature.txt"), "good state\n"); err != nil {
+ t.Fatal(err)
+ }
+ wantTree, err := runner.currentTreeSHA()
+ if err != nil {
+ t.Fatal(err)
+ }
+ commitSHA, err := runner.soloRecordTree(wantTree, "checkpoint")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The debris: a file added AND tracked after the checkpoint, the way
+ // eager-commit tracks everything the model writes.
+ if err := writeFile(filepath.Join(workspace, "probe.test.js"), "debris\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "add", "probe.test.js"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "commit", "-m", "wip(edit): probe.test.js"); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, "feature.txt"), "broken state\n"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := runner.soloRestoreTree(commitSHA, wantTree); err != nil {
+ t.Fatalf("restore failed: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "probe.test.js")); !os.IsNotExist(err) {
+ t.Fatalf("added file survived the restore (stat err %v)", err)
+ }
+ got, err := runner.currentTreeSHA()
+ if err != nil || got != wantTree {
+ t.Fatalf("restored tree %q (err %v), want %q", got, err, wantTree)
+ }
+}
+
+func TestATurnKilledByADroppedStreamIsRetriedInTheSameSession(t *testing.T) {
+ // One dropped stream must not end the run: the run layer owns the only
+ // retry and resumes the persisted session.
+ runner, state, _, events := soloPipeline(t)
+ outcome := soloOutcome{}
+ turns := 0
+ runner.turnForTest = func(_ context.Context, _, prompt string) (turnResult, error) {
+ turns++
+ switch turns {
+ case 1:
+ return turnResult{}, errors.New("stream error: unexpected EOF")
+ case 2:
+ if prompt != soloRecoveryPrompt() ||
+ !strings.Contains(prompt, "failed and is not in context") {
+ t.Fatalf("retry prompt does not say what happened: %q", prompt)
+ }
+ if err := writeFile(filepath.Join(runner.workspace, "fix.go"), "package fix\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil {
+ t.Fatalf("freeze during the retried turn: %v", err)
+ }
+ return turnResult{}, nil
+ default:
+ t.Fatalf("turn %d should not run", turns)
+ return turnResult{}, nil
+ }
+ }
+ if err := runner.soloConverse(context.Background(), "fix it", state, &outcome); err != nil {
+ t.Fatalf("converse: %v", err)
+ }
+ if outcome.TerminalTrigger != "submitted" || outcome.Nudges != 0 {
+ t.Fatalf("trigger %q nudges %d, want submitted with 0 nudges",
+ outcome.TerminalTrigger, outcome.Nudges)
+ }
+ var retried []map[string]any
+ for _, event := range soloStageEvents(t, events, "implement") {
+ if event["status"] == "transport-retry" {
+ retried = append(retried, event)
+ }
+ }
+ if len(retried) != 1 || retried[0]["class"] != "unexpected-eof" ||
+ retried[0]["retry"] != float64(1) ||
+ retried[0]["max_retries"] != float64(soloMaxRecoveryRetries) ||
+ retried[0]["delay_ms"] != float64(5_000) {
+ t.Fatalf("transport-retry events = %#v", retried)
+ }
+}
+
+func TestExhaustedTransportRetriesStillGetALandingTurnAndAnHonestError(t *testing.T) {
+ runner, state, _, events := soloPipeline(t)
+ outcome := soloOutcome{}
+ turns := 0
+ status := uint64(503)
+ runner.turnForTest = func(context.Context, string, string) (turnResult, error) {
+ turns++
+ return turnResult{}, &modelTurnError{
+ kind: "APIError", message: "provider down", statusCode: &status,
+ responseBody: `{"metadata":{"error_type":"provider_unavailable"}}`,
+ }
+ }
+ err := runner.soloConverse(context.Background(), "fix it", state, &outcome)
+ if err == nil || !strings.Contains(err.Error(), "provider down") {
+ t.Fatalf("converse err = %v, want the original provider error", err)
+ }
+ if outcome.TerminalTrigger != "turn-error" {
+ t.Fatalf("trigger %q, want turn-error", outcome.TerminalTrigger)
+ }
+ // 1 original turn + 3 transport retries + 1 landing turn.
+ if turns != 5 {
+ t.Fatalf("model turns = %d, want 5", turns)
+ }
+ retries, landings := 0, 0
+ for _, event := range soloStageEvents(t, events, "implement") {
+ if event["status"] == "transport-retry" {
+ retries++
+ if event["class"] != "provider-5xx" || event["http_status"] != float64(503) ||
+ event["provider_code"] != "provider_unavailable" ||
+ event["max_retries"] != float64(soloMaxRecoveryRetries) {
+ t.Fatalf("structured retry event = %#v", event)
+ }
+ }
+ }
+ for _, event := range soloStageEvents(t, events, "landing") {
+ if event["status"] == "repair-turn" {
+ landings++
+ }
+ }
+ if retries != soloMaxRecoveryRetries || landings != 1 {
+ t.Fatalf("retries=%d landings=%d, want %d and 1",
+ retries, landings, soloMaxRecoveryRetries)
+ }
+}
+
+func TestTransientTurnErrorSeparatesTransportFromDecisions(t *testing.T) {
+ for _, tc := range []struct {
+ err error
+ class string
+ }{
+ {fmt.Errorf("stream: %w", io.ErrUnexpectedEOF), "unexpected-eof"},
+ {errors.New("Post \"https://x\": read: connection reset by peer"), "connection-reset"},
+ {errors.New("write: broken pipe"), "broken-pipe"},
+ {errors.New("net/http: TLS handshake timeout"), "tls-handshake-timeout"},
+ {errors.New("http2: server sent GOAWAY and closed the connection"), "http2-goaway"},
+ {errors.New("SSE read timed out"), "sse-read-timeout"},
+ {errors.New("fetch failed: getaddrinfo EAI_AGAIN"), "fetch-failed"},
+ {errors.New("Upstream error: provider_unavailable; retry after 2s"), "provider-unavailable"},
+ {errors.New("Service unavailable"), "provider-unavailable"},
+ {errors.New("You can retry your request, or contact support"), "provider-retry-requested"},
+ {context.Canceled, ""},
+ {context.DeadlineExceeded, ""},
+ {fmt.Errorf("turn: %w", context.Canceled), ""},
+ {errors.New("assistant error: invalid request"), ""},
+ {errors.New("status 400: bad request"), ""},
+ {nil, ""},
+ } {
+ info, transient := transientTurnError(tc.err)
+ if info.Class != tc.class || transient != (tc.class != "") {
+ t.Errorf("transientTurnError(%v) = %q,%v; want %q", tc.err, info.Class, transient, tc.class)
+ }
+ }
+}
+
+func TestTransientTurnErrorUsesStructuredProviderStatusAndExcludesQuota(t *testing.T) {
+ status503 := uint64(503)
+ providerFailure := &modelTurnError{
+ kind: "APIError",
+ message: "Upstream error",
+ statusCode: &status503,
+ responseBody: `{"error":{"metadata":{"error_type":"provider_unavailable"}}}`,
+ }
+ info, transient := transientTurnError(providerFailure)
+ if !transient || info.Class != "provider-5xx" || info.StatusCode == nil ||
+ *info.StatusCode != 503 || info.ProviderCode != "provider_unavailable" {
+ t.Fatalf("structured 503 classification = %#v,%v", info, transient)
+ }
+
+ status429 := uint64(429)
+ quota := &modelTurnError{
+ kind: "APIError", message: "insufficient_quota: billing limit reached",
+ statusCode: &status429,
+ }
+ if info, transient := transientTurnError(quota); transient || info.Class != "" {
+ t.Fatalf("quota classification = %#v,%v; want terminal", info, transient)
+ }
+}
+
+func TestSubmitRefusalsAreCountableEvents(t *testing.T) {
+ // A refusal that travels only as tool-call error text cannot be counted
+ // without opening a log. Every refusal is an event with a reason class.
+ runner, state, _, events := soloPipeline(t)
+
+ // Refusal 1: nothing changed.
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("empty")); err == nil {
+ t.Fatal("an unchanged tree must refuse")
+ }
+ // Refusal 2: a change but no checklist.
+ if err := writeFile(filepath.Join(runner.workspace, "fix.go"), "package fix\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("no checklist")); err == nil {
+ t.Fatal("a missing checklist must refuse")
+ }
+ // A successful freeze, then refusal 3: a second submission.
+ if err := writeFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md"),
+ "- [x] done\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("real")); err != nil {
+ t.Fatalf("freeze: %v", err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("again")); err == nil {
+ t.Fatal("a second submission must refuse")
+ }
+
+ var classes []string
+ for _, event := range soloStageEvents(t, events, "submit") {
+ if event["status"] == "refused" {
+ class, _ := event["reason_class"].(string)
+ if detail, _ := event["detail"].(string); detail == "" {
+ t.Fatalf("refusal %q carries no detail", class)
+ }
+ classes = append(classes, class)
+ }
+ }
+ want := []string{"empty-tree", "no-checklist", "already-submitted"}
+ if strings.Join(classes, ",") != strings.Join(want, ",") {
+ t.Fatalf("refusal classes = %v, want %v", classes, want)
+ }
+}
diff --git a/internal/seniordev/app/stage_data.go b/internal/seniordev/app/stage_data.go
new file mode 100644
index 0000000000..bfc57a75e7
--- /dev/null
+++ b/internal/seniordev/app/stage_data.go
@@ -0,0 +1,114 @@
+//go:build !windows
+
+package app
+
+import (
+ "encoding/json"
+ "sort"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+)
+
+// A stage's data goes two places. The whole of it goes to stderr, one line per
+// stage, for a person reading why the run did what it did (eventWriter's
+// noteStage). A small copy of it goes on the protocol's `stage` record, for
+// codeaf's page to say in words: which attempt, how many requirements were
+// ticked, how many files the hand-in held, what the project's own check
+// found.
+//
+// THE COPY IS CURATED, NEVER COMPUTED. Every value on the record is a value the
+// run already put in the stage's data; this file only chooses which, shortens a
+// sentence and counts a list. Nothing here is new knowledge, and nothing here
+// reaches the model.
+
+// stageDataKeys are the keys a stage record may carry, each a plain fact a
+// page can say. Tree and commit ids, paths, pools of models and the run's
+// environment are left to stderr: they are machinery, and a page has nothing
+// to say with them.
+var stageDataKeys = map[string]bool{
+ // implement: the attempt, the retries and corrections.
+ "attempt": true, "retry": true, "max_retries": true, "delay_ms": true,
+ "class": true, "http_status": true, "correction": true,
+ "budget_exhausted": true, "transport_retries": true,
+ // submit, and the reasons given anywhere.
+ "reason": true, "reason_class": true, "detail": true, "error": true,
+ "checklist_satisfied": true, "checklist_items": true, "checklist_ticked": true,
+ "patch_bytes": true, "patch_files": true,
+ // patch-summary.
+ "files": true, "additions": true, "deletions": true, "binary_files": true,
+ // verification and the checks of the tree.
+ "commands": true, "vacuous": true, "phase": true, "failing": true,
+ "timed_out": true, "suite_dead": true, "safety_regression": true,
+ // landing and ship.
+ "source": true, "timeout_ms": true,
+ // bootstrap and intake.
+ "recorder": true, "spec_bytes": true,
+ // compaction-capacity and compaction.
+ "limit_tokens": true, "pinned_capacity_tokens": true,
+ "before_tokens": true, "after_tokens": true, "summary_status": true,
+ // model-switch.
+ "from": true, "to": true,
+}
+
+// stageDataTextMost is the most bytes one sentence on the record keeps: a
+// reason or an error is read by a person in one row, and the whole of it is on
+// stderr.
+const stageDataTextMost = 160
+
+// stageRecordData is a stage's data as the protocol record carries it: the
+// allowed keys, each a number, a yes or no, or a sentence cut to
+// [stageDataTextMost] bytes; a list counted rather than carried (a
+// verification's commands are each a step record of their own); and the whole
+// held to [delegate.StageDataCap] by dropping the longest sentences first. Nil
+// when nothing is left, so a stage with nothing to say carries no data.
+func stageRecordData(data map[string]any) json.RawMessage {
+ kept := map[string]any{}
+ for key, value := range data {
+ if !stageDataKeys[key] {
+ continue
+ }
+ switch v := value.(type) {
+ case string:
+ if v != "" {
+ kept[key] = clipBytes(oneLine(v), stageDataTextMost)
+ }
+ case bool, int, int64, uint64, float64:
+ kept[key] = v
+ case []any:
+ kept[key] = len(v)
+ }
+ }
+ for len(kept) > 0 {
+ raw, err := json.Marshal(kept)
+ if err != nil {
+ return nil
+ }
+ if len(raw) <= delegate.StageDataCap {
+ return raw
+ }
+ delete(kept, longestText(kept))
+ }
+ return nil
+}
+
+// longestText is the key whose value takes the most bytes, a sentence before a
+// number, and the first in key order among equals, so the cut is the same on
+// every run.
+func longestText(kept map[string]any) string {
+ keys := make([]string, 0, len(kept))
+ for key := range kept {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ longest, most := keys[0], -1
+ for _, key := range keys {
+ size := 0
+ if text, ok := kept[key].(string); ok {
+ size = len(text) + 1
+ }
+ if size > most {
+ longest, most = key, size
+ }
+ }
+ return longest
+}
diff --git a/internal/seniordev/app/stage_data_test.go b/internal/seniordev/app/stage_data_test.go
new file mode 100644
index 0000000000..d78b8cf32c
--- /dev/null
+++ b/internal/seniordev/app/stage_data_test.go
@@ -0,0 +1,91 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction"
+)
+
+// A STAGE RECORD CARRIES A CURATED COPY OF ITS DATA: the plain facts a page can
+// say, a list counted rather than carried, a sentence cut to a row, and none of
+// the machinery — tree ids, paths, model pools — that stays on stderr.
+func TestAStageRecordCarriesACuratedCopyOfItsData(t *testing.T) {
+ raw := stageRecordData(map[string]any{
+ "reason": "tests pass " + strings.Repeat("and more ", 40), "evidence": "go test ./... ok",
+ "checklist_satisfied": true, "checklist_items": 5, "checklist_ticked": 4,
+ "patch_bytes": 812, "patch_files": 4, "tree_sha": "t1", "commit_sha": "c1",
+ "commands": []any{map[string]any{"cmd": "go test ./..."}, map[string]any{"cmd": "go build ./..."}},
+ "workspace": "/tmp/copy",
+ })
+ var got map[string]any
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("data %s: %v", raw, err)
+ }
+ for _, key := range []string{"tree_sha", "commit_sha", "workspace", "evidence"} {
+ if _, ok := got[key]; ok {
+ t.Fatalf("the record carries %q, which is machinery: %s", key, raw)
+ }
+ }
+ if got["checklist_items"] != float64(5) || got["checklist_ticked"] != float64(4) || got["patch_files"] != float64(4) || got["checklist_satisfied"] != true {
+ t.Fatalf("the record lost a fact: %s", raw)
+ }
+ if got["commands"] != float64(2) {
+ t.Fatalf("commands = %v, want the list counted", got["commands"])
+ }
+ if reason := got["reason"].(string); len(reason) > stageDataTextMost {
+ t.Fatalf("the reason is %d bytes, want it cut to %d", len(reason), stageDataTextMost)
+ }
+ if stageRecordData(map[string]any{"tree_sha": "t1"}) != nil || stageRecordData(nil) != nil {
+ t.Fatal("a stage with nothing to say carries data")
+ }
+}
+
+// THE COPY FITS THE PROTOCOL'S CAP, whatever the stage held: the longest
+// sentences go first, and the numbers stay.
+func TestAStageRecordsDataFitsTheCap(t *testing.T) {
+ data := map[string]any{"attempt": 2, "retry": 1, "max_retries": 3}
+ for _, key := range []string{"reason", "detail", "error", "class", "reason_class", "phase", "source", "recorder", "summary_status", "from", "to"} {
+ data[key] = strings.Repeat("é", 400)
+ }
+ raw := stageRecordData(data)
+ if len(raw) > delegate.StageDataCap || len(raw) == 0 {
+ t.Fatalf("data is %d bytes, want some, and at most %d", len(raw), delegate.StageDataCap)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got["attempt"] != float64(2) || got["retry"] != float64(1) || got["max_retries"] != float64(3) {
+ t.Fatalf("the numbers were dropped before the sentences: %s", raw)
+ }
+}
+
+// A COMPACTION IS A STAGE, reported after the history was rewritten: summarized
+// when the model's summary stands, fallback when the deterministic record
+// stood in, with the tokens before and after.
+func TestACompactionIsReportedAsAStage(t *testing.T) {
+ var output bytes.Buffer
+ sink := newSeniorDevCompactionDecisionSink(nil, newEventWriter(&output))
+ sink.CompactionDecision(compaction.CompactionDecision{SummaryStatus: "valid", Before: 120000, After: 9000})
+ sink.CompactionDecision(compaction.CompactionDecision{SummaryStatus: "summary-error"})
+ var statuses []string
+ for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil {
+ t.Fatal(err)
+ }
+ if value.Stage != "compaction" {
+ t.Fatalf("event = %+v, want the compaction stage", value)
+ }
+ statuses = append(statuses, value.Status)
+ }
+ if strings.Join(statuses, ",") != "summarized,fallback" {
+ t.Fatalf("statuses = %v, want summarized then fallback", statuses)
+ }
+}
diff --git a/internal/seniordev/app/stages_test.go b/internal/seniordev/app/stages_test.go
new file mode 100644
index 0000000000..e95599d061
--- /dev/null
+++ b/internal/seniordev/app/stages_test.go
@@ -0,0 +1,89 @@
+//go:build !windows
+
+package app
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// THE HELLO NAMES THE RUN'S STAGES, ALL OF THEM AND ONLY THEM. codeaf draws
+// the whole track of a run from its hello before the run has walked it, so a
+// stage the run can emit and the hello did not name is a stop on no track,
+// and a name the run never emits is a stop nobody reaches.
+//
+// The stages are read out of this package's sources with go/parser, as the
+// first argument of every `.stage(…)` and `.emitStage(…)` call, so a stage
+// added anywhere is held to the list the day it is written.
+func TestTheHelloNamesEveryStageTheRunCanEmit(t *testing.T) {
+ entries, err := os.ReadDir(".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fset := token.NewFileSet()
+ emitted := map[string]string{}
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ file, err := parser.ParseFile(fset, name, nil, 0)
+ if err != nil {
+ t.Fatalf("parse %s: %v", name, err)
+ }
+ ast.Inspect(file, func(node ast.Node) bool {
+ call, ok := node.(*ast.CallExpr)
+ if !ok || len(call.Args) == 0 {
+ return true
+ }
+ selector, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || (selector.Sel.Name != "stage" && selector.Sel.Name != "emitStage") {
+ return true
+ }
+ literal, ok := call.Args[0].(*ast.BasicLit)
+ if !ok || literal.Kind != token.STRING {
+ return true
+ }
+ stage, err := strconv.Unquote(literal.Value)
+ if err == nil {
+ emitted[stage] = fset.Position(literal.Pos()).String()
+ }
+ return true
+ })
+ }
+ if len(emitted) < 10 {
+ t.Fatalf("only %d stages were read out of the sources; the reader has stopped working", len(emitted))
+ }
+ named := map[string]bool{}
+ for _, stage := range Stages {
+ if named[stage] {
+ t.Errorf("the hello names %q twice", stage)
+ }
+ named[stage] = true
+ }
+ var unnamed, unreached []string
+ for stage, where := range emitted {
+ if !named[stage] {
+ unnamed = append(unnamed, stage+" ("+where+")")
+ }
+ }
+ for _, stage := range Stages {
+ if _, ok := emitted[stage]; !ok {
+ unreached = append(unreached, stage)
+ }
+ }
+ sort.Strings(unnamed)
+ sort.Strings(unreached)
+ if len(unnamed) > 0 {
+ t.Errorf("the run emits stages its hello does not name: %v", unnamed)
+ }
+ if len(unreached) > 0 {
+ t.Errorf("the hello names stages the run never emits: %v", unreached)
+ }
+}
diff --git a/internal/seniordev/app/statctime_darwin.go b/internal/seniordev/app/statctime_darwin.go
new file mode 100644
index 0000000000..1b8ba88b4c
--- /dev/null
+++ b/internal/seniordev/app/statctime_darwin.go
@@ -0,0 +1,13 @@
+//go:build darwin
+
+// Darwin names the stat ctime field Ctimespec, not Ctim; same value, same units.
+package app
+
+import (
+ "syscall"
+ "time"
+)
+
+func statChangedNanos(stat *syscall.Stat_t) int64 {
+ return int64(stat.Ctimespec.Sec)*int64(time.Second) + int64(stat.Ctimespec.Nsec)
+}
diff --git a/internal/seniordev/app/statctime_linux.go b/internal/seniordev/app/statctime_linux.go
new file mode 100644
index 0000000000..bedf4fc7b7
--- /dev/null
+++ b/internal/seniordev/app/statctime_linux.go
@@ -0,0 +1,13 @@
+//go:build linux
+
+// Linux spelling of the stat ctime field read by durable_sessions.go's projection mark.
+package app
+
+import (
+ "syscall"
+ "time"
+)
+
+func statChangedNanos(stat *syscall.Stat_t) int64 {
+ return int64(stat.Ctim.Sec)*int64(time.Second) + int64(stat.Ctim.Nsec)
+}
diff --git a/internal/seniordev/app/step_ids.go b/internal/seniordev/app/step_ids.go
new file mode 100644
index 0000000000..d7eae80c3a
--- /dev/null
+++ b/internal/seniordev/app/step_ids.go
@@ -0,0 +1,210 @@
+//go:build !windows
+
+package app
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// The steps of senior-dev's own process, as a `step` record names the one an
+// action served. They are the parts of the one model context the run
+// instruction lays out (solo_prompt.go) — read the spec, explore, pin a check,
+// list the requirements, implement, submit — and the independent check the run
+// makes of the tree itself afterwards.
+//
+// THEY ARE A REPORT, NEVER A PLAN. Inside the model context the order is the
+// model's own: it may explore after it has pinned, and write its checklist last.
+// A step id says which part of the process an action serves, so an id recurs
+// whenever the model comes back to that part; nothing here moves the model from
+// one to the next, and nothing the model sees depends on them.
+//
+// ONE SOURCE OF TRUTH: these constants are the ids on the wire, the classifier
+// below answers only them, and senior-dev's page words are keyed by them
+// (internal/seniordev's actions.go).
+const (
+ // StepBrief is reading the spec the brief was written down as.
+ StepBrief = "brief"
+ // StepExplore is reading, searching and running commands before the first
+ // change to a project file.
+ StepExplore = "explore"
+ // StepPin is writing down the command that shows the work passes.
+ StepPin = "pin"
+ // StepChecklist is listing the request's requirements, and ticking them.
+ StepChecklist = "checklist"
+ // StepImplement is every change to a project file, and whatever the model
+ // reads or runs once it has made one.
+ StepImplement = "implement"
+ // StepSubmit is the submit tool, and anything after an accepted submit.
+ StepSubmit = "submit"
+ // StepVerify is senior-dev running the project's own build and tests itself,
+ // with no model: after the hand-in, and when it checks the tree mid-run.
+ StepVerify = "verify"
+)
+
+// Steps is every step id, in the order a run first reaches them when its
+// model works through the process as the instruction lays it out.
+var Steps = []string{StepBrief, StepExplore, StepPin, StepChecklist, StepImplement, StepSubmit, StepVerify}
+
+// The files senior-dev keeps its own records in, inside the folder it works in
+// (seniorDevArtifactPathspecs keeps them out of the answer). An action on one
+// of them serves that record's step, whatever tool it took.
+const (
+ seniorDevSpec = seniorDevDataDirectory + "/spec.md"
+ seniorDevPinned = seniorDevDataDirectory + "/pinned.txt"
+ seniorDevChecklist = seniorDevDataDirectory + "/checklist.md"
+)
+
+// stepProgress is what the step classifier knows about the run so far: whether
+// an edit tool has changed a project file, and whether a submit was accepted.
+// It only ever moves forward. A finished tool call moves the first (stepOf);
+// only the freeze's own stage record moves the second (afterStage).
+type stepProgress struct {
+ changed bool
+ submitted bool
+}
+
+// The stage record the freeze writes once it has captured the tree, and at no
+// other time (solo.go's soloFreezeWithContext): the one record that says a
+// submit was accepted.
+const (
+ frozenStage = "submit"
+ frozenStatus = "frozen"
+)
+
+// afterStage is the run's progress after a stage record. Only the freeze's
+// record moves it: from then on the tree is frozen, and everything is the
+// submit step.
+func (progress stepProgress) afterStage(stage, status string) stepProgress {
+ if stage == frozenStage && status == frozenStatus {
+ progress.submitted = true
+ }
+ return progress
+}
+
+// stepAction is one finished tool call as the classifier reads it: the tool,
+// what it was aimed at — the file a file tool named, a shell's command, a
+// patch's text — and whether it failed.
+type stepAction struct {
+ tool string
+ target string
+ failed bool
+}
+
+// editTools are the tools that change a file. Only their success moves a run
+// from exploring to implementing: a shell command may change files too, but a
+// reader of the command cannot tell which, and the instruction's own line
+// between the two parts is the first edit.
+var editTools = map[string]bool{"edit": true, "write": true, "apply_patch": true}
+
+// stepOf is the step a finished tool call served, and the run's progress after
+// it. It is pure: the same call on the same progress answers the same step.
+//
+// - Once a submit has been accepted, everything is the submit step: the
+// tree is frozen and the run is handing in.
+// - The submit tool is the submit step, accepted or refused, and it moves
+// nothing. A refused submit tells its model why and lets it keep working,
+// so it settles as a completed call exactly as an accepted one does
+// (tool/submit.go): the call cannot say which it was, and the freeze's
+// own stage record, which comes first, does (afterStage).
+// - An action on one of senior-dev's own records is that record's step: the
+// spec (brief), the pinned check (pin), the checklist.
+// - A successful edit to a project file is the first change, and it and
+// everything after it is implementing; before it, exploring.
+//
+// `question` is always refused in a run nobody attends (runtime.go), so it
+// changes nothing and is read as whichever part the model was in when it
+// asked.
+func stepOf(action stepAction, progress stepProgress) (string, stepProgress) {
+ if progress.submitted {
+ return StepSubmit, progress
+ }
+ if action.tool == "submit" {
+ return StepSubmit, progress
+ }
+ if record := seniorDevRecordStep(action); record != "" {
+ return record, progress
+ }
+ if editTools[action.tool] {
+ if !action.failed {
+ progress.changed = true
+ }
+ return StepImplement, progress
+ }
+ if progress.changed {
+ return StepImplement, progress
+ }
+ return StepExplore, progress
+}
+
+// seniorDevRecordStep is the step of an action on one of senior-dev's own
+// records, and "" for an action on anything else. A patch counts as one only
+// when every file it touches is one of them: a patch that also changes a
+// project file is implementing.
+func seniorDevRecordStep(action stepAction) string {
+ target := filepath.ToSlash(action.target)
+ if action.tool == "apply_patch" {
+ files := patchFiles(target)
+ if len(files) == 0 {
+ return ""
+ }
+ step := ""
+ for _, file := range files {
+ record := recordStepOf(file)
+ if record == "" {
+ return ""
+ }
+ if step == "" {
+ step = record
+ }
+ }
+ return step
+ }
+ return recordStepOf(target)
+}
+
+// recordStepOf names the record a path or a command mentions, first the spec,
+// then the pinned check, then the checklist.
+func recordStepOf(text string) string {
+ for _, record := range []struct{ file, step string }{
+ {seniorDevSpec, StepBrief},
+ {seniorDevPinned, StepPin},
+ {seniorDevChecklist, StepChecklist},
+ } {
+ if mentionsPath(text, record.file) {
+ return record.step
+ }
+ }
+ return ""
+}
+
+// mentionsPath reports whether text names the file at a path boundary: the
+// relative path itself, or the same path at the end of a longer one — so
+// `/copy/.senior-dev/spec.md` is the spec and `my.senior-dev/spec.md` is not.
+func mentionsPath(text, file string) bool {
+ for from := 0; ; {
+ at := strings.Index(text[from:], file)
+ if at < 0 {
+ return false
+ }
+ at += from
+ if at == 0 || strings.ContainsRune("/ \t\n'\"=<>(;&|", rune(text[at-1])) {
+ return true
+ }
+ from = at + len(file)
+ }
+}
+
+// patchFiles are the files an apply_patch text touches, from its own headers.
+func patchFiles(text string) []string {
+ var files []string
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ for _, header := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
+ if name, ok := strings.CutPrefix(line, header); ok && strings.TrimSpace(name) != "" {
+ files = append(files, strings.TrimSpace(name))
+ }
+ }
+ }
+ return files
+}
diff --git a/internal/seniordev/app/step_ids_test.go b/internal/seniordev/app/step_ids_test.go
new file mode 100644
index 0000000000..88c6cf9337
--- /dev/null
+++ b/internal/seniordev/app/step_ids_test.go
@@ -0,0 +1,200 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+)
+
+// EVERY FINISHED TOOL CALL NAMES THE STEP OF THE PROCESS IT SERVED, from the
+// tool, what it was aimed at and the run's progress — and the progress only
+// ever moves forward: the first successful edit to a project file turns
+// exploring into implementing, and an accepted submit turns everything after
+// it into handing in. A submit call itself moves nothing, because a refused
+// one completes exactly as an accepted one does; the freeze's stage record,
+// written inside the call before its step, is what moved the progress of the
+// accepted one (TestOnlyTheFreezeSaysASubmitWasAccepted).
+func TestAToolCallNamesTheStepItServed(t *testing.T) {
+ fresh := stepProgress{}
+ changed := stepProgress{changed: true}
+ submitted := stepProgress{changed: true, submitted: true}
+ for _, tc := range []struct {
+ name string
+ action stepAction
+ progress stepProgress
+ want string
+ after stepProgress
+ }{
+ {"reading the spec", stepAction{tool: "read", target: ".senior-dev/spec.md"}, fresh, StepBrief, fresh},
+ {"reading the spec by its absolute path", stepAction{tool: "read", target: "/copy/.senior-dev/spec.md"}, fresh, StepBrief, fresh},
+ {"a shell printing the spec", stepAction{tool: "bash", target: "cat .senior-dev/spec.md"}, changed, StepBrief, changed},
+ {"a file that only ends like the spec", stepAction{tool: "read", target: "my.senior-dev/spec.md"}, fresh, StepExplore, fresh},
+ {"reading code before any change", stepAction{tool: "read", target: "internal/auth/middleware.go"}, fresh, StepExplore, fresh},
+ {"a search before any change", stepAction{tool: "grep", target: "internal"}, fresh, StepExplore, fresh},
+ {"a command before any change", stepAction{tool: "bash", target: "go test ./internal/auth/..."}, fresh, StepExplore, fresh},
+ {"a fetch before any change", stepAction{tool: "webfetch", target: "https://go.dev/doc"}, fresh, StepExplore, fresh},
+ {"writing the pinned check", stepAction{tool: "write", target: ".senior-dev/pinned.txt"}, fresh, StepPin, fresh},
+ {"a shell writing the pinned check", stepAction{tool: "bash", target: "echo 'go test ./...' > .senior-dev/pinned.txt"}, fresh, StepPin, fresh},
+ {"writing the checklist", stepAction{tool: "write", target: ".senior-dev/checklist.md"}, changed, StepChecklist, changed},
+ {"ticking the checklist", stepAction{tool: "edit", target: "/copy/.senior-dev/checklist.md"}, changed, StepChecklist, changed},
+ {"the first edit to a project file", stepAction{tool: "edit", target: "internal/auth/middleware.go"}, fresh, StepImplement, changed},
+ {"an edit that failed changes nothing", stepAction{tool: "edit", target: "internal/x.go", failed: true}, fresh, StepImplement, fresh},
+ {"a new file", stepAction{tool: "write", target: "internal/auth/store.go"}, fresh, StepImplement, changed},
+ {"a patch to a project file", stepAction{tool: "apply_patch", target: "*** Begin Patch\n*** Update File: a.go\n@@\n-x\n+y\n*** End Patch"}, fresh, StepImplement, changed},
+ {"a patch to the checklist alone", stepAction{tool: "apply_patch", target: "*** Begin Patch\n*** Update File: .senior-dev/checklist.md\n@@\n-[ ] a\n+[x] a\n*** End Patch"}, changed, StepChecklist, changed},
+ {"a read after the first change", stepAction{tool: "read", target: "internal/auth/middleware.go"}, changed, StepImplement, changed},
+ {"a command after the first change", stepAction{tool: "bash", target: "go test ./..."}, changed, StepImplement, changed},
+ {"a question before any change", stepAction{tool: "question"}, fresh, StepExplore, fresh},
+ {"a question after a change", stepAction{tool: "question"}, changed, StepImplement, changed},
+ {"a submit moves nothing, since a refused one completes too", stepAction{tool: "submit"}, changed, StepSubmit, changed},
+ {"a submit that failed moves nothing either", stepAction{tool: "submit", failed: true}, changed, StepSubmit, changed},
+ {"an accepted submit, after its freeze", stepAction{tool: "submit"}, submitted, StepSubmit, submitted},
+ {"anything after an accepted submit", stepAction{tool: "edit", target: "a.go"}, submitted, StepSubmit, submitted},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, after := stepOf(tc.action, tc.progress)
+ if got != tc.want || after != tc.after {
+ t.Fatalf("stepOf(%+v, %+v) = %q, %+v; want %q, %+v", tc.action, tc.progress, got, after, tc.want, tc.after)
+ }
+ })
+ }
+}
+
+// ONLY THE FREEZE SAYS A SUBMIT WAS ACCEPTED: its `submit · frozen` record
+// moves the progress, and a refusal's `submit · refused` and every other stage
+// leave it where it was. Nothing moves it back.
+func TestOnlyTheFreezeSaysASubmitWasAccepted(t *testing.T) {
+ changed := stepProgress{changed: true}
+ submitted := stepProgress{changed: true, submitted: true}
+ for _, tc := range []struct {
+ stage, status string
+ progress stepProgress
+ want stepProgress
+ }{
+ {"submit", "frozen", changed, submitted},
+ {"submit", "refused", changed, changed},
+ {"implement", "running", changed, changed},
+ {"verification", "pass", changed, changed},
+ {"submit", "refused", submitted, submitted},
+ } {
+ if got := tc.progress.afterStage(tc.stage, tc.status); got != tc.want {
+ t.Fatalf("%+v after %s · %s = %+v; want %+v", tc.progress, tc.stage, tc.status, got, tc.want)
+ }
+ }
+}
+
+// THE STEPS ARE THE ONE LIST: every id the classifier can answer is in Steps,
+// once, and verify — which no tool call is — is there for the run's own checks.
+func TestEveryStepIdIsInTheOneList(t *testing.T) {
+ seen := map[string]bool{}
+ for _, id := range Steps {
+ if seen[id] {
+ t.Fatalf("step %q is listed twice", id)
+ }
+ seen[id] = true
+ }
+ for _, id := range []string{StepBrief, StepExplore, StepPin, StepChecklist, StepImplement, StepSubmit, StepVerify} {
+ if !seen[id] {
+ t.Fatalf("step %q is not in Steps", id)
+ }
+ }
+}
+
+// A REFUSED SUBMIT MOVES NOTHING. The submit tool tells its model why it was
+// refused and lets it keep working, so a refusal settles as a completed call
+// exactly as an acceptance does (tool/submit.go); only an accepted submit
+// freezes the tree, and only after one is everything the submit step. Here the
+// model submits an unchanged tree, then a change with no checklist, and is
+// refused both times; what it does after each refusal is still the part of the
+// process it was in, and only what follows the third, accepted submit is
+// handing in.
+func TestOnlyAnAcceptedSubmitTurnsWhatFollowsIntoHandingIn(t *testing.T) {
+ runner, state, _, events := soloPipeline(t)
+ checklist := filepath.Join(runner.workspace, ".senior-dev", "checklist.md")
+ if err := os.Remove(checklist); err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ // finish reports one tool call the way the step loop settles it
+ // (engine/steploop/processor.go): a call whose tool returned an error
+ // fails, and every other call completes.
+ finish := func(tool string, input map[string]any, result steploop.ToolResult, err error) {
+ calls++
+ callID := fmt.Sprintf("c%d", calls)
+ if err != nil {
+ runner.events.busEvent(toolPartPayload(callID, tool, "error", input, "", err.Error()))
+ return
+ }
+ runner.events.busEvent(toolPartPayload(callID, tool, "completed", input, result.Output, ""))
+ }
+ did := func(tool string, input map[string]any) {
+ finish(tool, input, steploop.ToolResult{Output: "ok"}, nil)
+ }
+ submit := func() steploop.ToolResult {
+ input := map[string]any{"reason": "done", "evidence": "make test: exit 0", "checklist_satisfied": true}
+ raw, err := json.Marshal(input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := runner.runtime.registry.Execute(context.Background(), steploop.ToolCall{
+ ID: "submit", Name: "submit", Input: raw, SessionID: "ses_solo",
+ })
+ finish("submit", input, result, err)
+ return result
+ }
+
+ did("read", map[string]any{"filePath": "README.md"})
+ if refused := submit(); refused.Title != "submit refused" || state.candidate() != nil {
+ t.Fatalf("a submit of an unchanged tree was not refused: %+v", refused)
+ }
+ did("grep", map[string]any{"pattern": "base"})
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ did("write", map[string]any{"filePath": "feature.txt"})
+ if refused := submit(); refused.Title != "submit refused" || state.candidate() != nil {
+ t.Fatalf("a submit with no checklist was not refused: %+v", refused)
+ }
+ if err := writeFile(checklist, "- [x] the feature\n"); err != nil {
+ t.Fatal(err)
+ }
+ did("write", map[string]any{"filePath": checklist})
+ did("edit", map[string]any{"filePath": "feature.txt"})
+ did("bash", map[string]any{"command": "make test"})
+ if accepted := submit(); accepted.Title != "submitted" || state.candidate() == nil {
+ t.Fatalf("a submit with a change and a checklist was not accepted: %+v", accepted)
+ }
+ did("edit", map[string]any{"filePath": "feature.txt"})
+
+ var got []string
+ for _, step := range streamSteps(t, events.Bytes()) {
+ got = append(got, step.Tool+" "+step.Step)
+ }
+ want := []string{
+ "read " + StepExplore,
+ "submit " + StepSubmit,
+ "grep " + StepExplore,
+ "write " + StepImplement,
+ "submit " + StepSubmit,
+ "write " + StepChecklist,
+ "edit " + StepImplement,
+ "bash " + StepImplement,
+ "submit " + StepSubmit,
+ "edit " + StepSubmit,
+ }
+ if len(got) != len(want) {
+ t.Fatalf("steps = %q, want %q", got, want)
+ }
+ for index := range want {
+ if got[index] != want[index] {
+ t.Fatalf("steps = %q, want %q", got, want)
+ }
+ }
+}
diff --git a/internal/seniordev/app/step_records.go b/internal/seniordev/app/step_records.go
new file mode 100644
index 0000000000..db35dcade4
--- /dev/null
+++ b/internal/seniordev/app/step_records.go
@@ -0,0 +1,252 @@
+//go:build !windows
+
+package app
+
+import (
+ "encoding/json"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+)
+
+// The `step` record projects one finished tool call into a shape a reader can
+// display without understanding the message model: what was run, what came
+// back, the tool, a command's exit code, and the step of senior-dev's process
+// it served. Every byte but the last is already inside the
+// `message.part.updated` payload for the same call, and the last is read off
+// those same payloads in the order the calls finished (step_ids.go) — this
+// rearranges what the run already knows, and learns nothing new.
+//
+// Nothing here reaches the model. The record is written by the event layer
+// after the tool result has been produced; it is not a prompt, not a tool
+// result, and not a message. The model's transcript is identical whether or
+// not anyone reads these.
+const (
+ // stepObservationMax caps the observation. A tool result can be a whole
+ // file or a full test log, and a reader that only renders steps should not
+ // have to hold one.
+ stepObservationMax = 2048
+ // stepCommandMax caps the argument rendered beside the tool name, which is
+ // a label rather than a payload.
+ stepCommandMax = 200
+)
+
+// stepRecord is one finished tool call. key deduplicates: a tool part is
+// republished as its state moves, so the same call arrives more than once in
+// the same terminal state. action is what the step classifier reads
+// (step_ids.go), step is the step it named, and exit is a command's exit code,
+// which the bash tool keeps in its metadata (tool/bash.go) and nowhere else.
+type stepRecord struct {
+ key string
+ command string
+ observation string
+ action stepAction
+ step string
+ exit *int
+ // added and removed are the lines a file tool's call added and removed,
+ // read off its metadata ([lineCounts]); nil for every other call.
+ added, removed *int
+}
+
+// toolStepRecord reads a bus payload and reports the finished tool call in it,
+// if it holds one. Pending and running states are ignored: a step is a thing
+// that happened, and only `completed` and `error` have happened.
+func toolStepRecord(value bus.Payload) (stepRecord, bool) {
+ if value.Type != "message.part.updated" {
+ return stepRecord{}, false
+ }
+ part := mapAt(object(value.Properties), "part")
+ if stringAt(part, "type") != "tool" {
+ return stepRecord{}, false
+ }
+ state := mapAt(part, "state")
+ status := stringAt(state, "status")
+ if status != "completed" && status != "error" {
+ return stepRecord{}, false
+ }
+ tool := stringAt(part, "tool")
+ input := mapAt(state, "input")
+ record := stepRecord{
+ key: "tool:" + stringAt(part, "callID") + ":" + status,
+ action: stepAction{tool: tool, target: stepTarget(input), failed: status == "error"},
+ exit: exitCode(mapAt(state, "metadata")),
+ }
+ if status == "completed" {
+ record.added, record.removed = lineCounts(tool, mapAt(state, "metadata"))
+ }
+ if argument := toolArgument(input); argument != "" {
+ record.command = tool + ": " + argument
+ } else {
+ record.command = tool
+ }
+ if status == "error" {
+ record.observation = stringAt(state, "error")
+ } else {
+ record.observation = stringAt(state, "output")
+ }
+ record.observation = clipBytes(record.observation, stepObservationMax)
+ return record, true
+}
+
+// stepTargetKeys are the inputs that say what an action was aimed at, for the
+// step classifier: the file a file tool named, a shell's command, a patch's
+// whole text, where a search looked. It reads the input whole — the label on
+// the record is cut to 200 bytes, and a patch names its files after its first
+// line.
+var stepTargetKeys = []string{"filePath", "command", "patchText", "path", "pattern"}
+
+func stepTarget(input map[string]any) string {
+ for _, key := range stepTargetKeys {
+ if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" {
+ return text
+ }
+ }
+ return ""
+}
+
+// lineCounts is the lines a file tool's call added and removed, from the
+// metadata the tool itself wrote: write's and edit's counts for the one file,
+// and apply_patch's summed over the files it touched. nil, nil for every other
+// tool, and for one whose metadata carried no counts.
+func lineCounts(tool string, metadata map[string]any) (*int, *int) {
+ switch tool {
+ case "write":
+ return wholeAt(metadata, "additions"), wholeAt(metadata, "deletions")
+ case "edit":
+ diff := mapAt(metadata, "filediff")
+ return wholeAt(diff, "additions"), wholeAt(diff, "deletions")
+ case "apply_patch":
+ files, _ := metadata["files"].([]any)
+ if len(files) == 0 {
+ return nil, nil
+ }
+ added, removed := 0, 0
+ for _, file := range files {
+ entry := object(file)
+ if n := wholeAt(entry, "additions"); n != nil {
+ added += *n
+ }
+ if n := wholeAt(entry, "deletions"); n != nil {
+ removed += *n
+ }
+ }
+ return &added, &removed
+ }
+ return nil, nil
+}
+
+// wholeAt is a whole number in a metadata object, nil when it is absent.
+func wholeAt(value map[string]any, key string) *int {
+ var n int
+ switch number := value[key].(type) {
+ case float64:
+ n = int(number)
+ case int:
+ n = number
+ default:
+ return nil
+ }
+ return &n
+}
+
+// exitCode is a tool's exit code from its metadata, nil when it reported none:
+// every tool but a shell, and a shell command killed at its ceiling.
+func exitCode(metadata map[string]any) *int {
+ var code int
+ switch value := metadata["exitCode"].(type) {
+ case float64:
+ if value != float64(int(value)) {
+ return nil
+ }
+ code = int(value)
+ case int:
+ code = value
+ default:
+ return nil
+ }
+ return &code
+}
+
+// toolArgumentKeys are the input fields that identify what a call was about,
+// most identifying first. A tool that names none of them falls back to its
+// first string input in key order, so a new tool still renders something.
+var toolArgumentKeys = []string{
+ "command", "filePath", "path", "pattern", "query", "url", "description",
+}
+
+func toolArgument(input map[string]any) string {
+ if input == nil {
+ return ""
+ }
+ for _, key := range toolArgumentKeys {
+ if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" {
+ return clipBytes(oneLine(text), stepCommandMax)
+ }
+ }
+ keys := make([]string, 0, len(input))
+ for key := range input {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" {
+ return clipBytes(oneLine(text), stepCommandMax)
+ }
+ }
+ return ""
+}
+
+// oneLine flattens a multi-line argument so the command reads as a label.
+func oneLine(text string) string {
+ return strings.Join(strings.Fields(text), " ")
+}
+
+// clipBytes truncates to at most max bytes without splitting a rune, so the
+// result is always valid UTF-8 and always encodes.
+func clipBytes(text string, max int) string {
+ if len(text) <= max {
+ return text
+ }
+ clipped := text[:max]
+ for len(clipped) > 0 && !utf8.ValidString(clipped) {
+ clipped = clipped[:len(clipped)-1]
+ }
+ return clipped
+}
+
+// The payload readers below were the stderr trace's (trace.go, which stayed
+// behind with the rest of senior-dev's command line); a step is read out of
+// the same loosely typed bus payloads, so they came with it.
+
+// object reads a payload value as a JSON object, converting a typed value
+// through its JSON form when it is not already a map.
+func object(value any) map[string]any {
+ if mapped, ok := value.(map[string]any); ok {
+ return mapped
+ }
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return nil
+ }
+ var mapped map[string]any
+ if json.Unmarshal(raw, &mapped) != nil {
+ return nil
+ }
+ return mapped
+}
+
+func mapAt(value map[string]any, key string) map[string]any { return object(valueAt(value, key)) }
+
+func valueAt(value map[string]any, key string) any {
+ if value == nil {
+ return nil
+ }
+ return value[key]
+}
+
+func stringAt(value map[string]any, key string) string {
+ result, _ := valueAt(value, key).(string)
+ return result
+}
diff --git a/internal/seniordev/app/step_records_test.go b/internal/seniordev/app/step_records_test.go
new file mode 100644
index 0000000000..f53de78069
--- /dev/null
+++ b/internal/seniordev/app/step_records_test.go
@@ -0,0 +1,183 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/bus"
+)
+
+func toolPartPayload(callID, tool, status string, input map[string]any, output, failure string) bus.Payload {
+ state := map[string]any{"status": status, "input": input}
+ switch status {
+ case "completed":
+ state["output"] = output
+ case "error":
+ state["error"] = failure
+ }
+ return bus.Payload{
+ ID: "evt-" + callID + "-" + status, Type: "message.part.updated",
+ Properties: map[string]any{
+ "sessionID": "ses-1",
+ "part": map[string]any{
+ "id": "part-" + callID, "type": "tool",
+ "callID": callID, "tool": tool, "state": state,
+ },
+ },
+ }
+}
+
+func streamSteps(t *testing.T, stream []byte) []event {
+ t.Helper()
+ var steps []event
+ for _, line := range bytes.Split(bytes.TrimSpace(stream), []byte("\n")) {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil || value.Type != "step" {
+ continue
+ }
+ steps = append(steps, value)
+ }
+ return steps
+}
+
+// A step is a thing that happened: only completed and error have happened, and
+// a tool part is republished as its state moves, so the same finished call
+// arrives more than once and must be reported once.
+func TestStepRecordsFireOncePerFinishedToolCall(t *testing.T) {
+ var stream bytes.Buffer
+ writer := newEventWriter(&stream)
+
+ writer.busEvent(toolPartPayload("c1", "bash", "pending", map[string]any{"command": "go test ./..."}, "", ""))
+ writer.busEvent(toolPartPayload("c1", "bash", "running", map[string]any{"command": "go test ./..."}, "", ""))
+ if got := streamSteps(t, stream.Bytes()); len(got) != 0 {
+ t.Fatalf("unfinished tool call produced %d step records, want 0", len(got))
+ }
+
+ writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok \tpkg\t0.3s", ""))
+ writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok \tpkg\t0.3s", ""))
+
+ steps := streamSteps(t, stream.Bytes())
+ if len(steps) != 1 {
+ t.Fatalf("step records = %d, want 1 (the republished part must not repeat)", len(steps))
+ }
+ if steps[0].Command != "bash: go test ./..." {
+ t.Fatalf("command = %q, want %q", steps[0].Command, "bash: go test ./...")
+ }
+ if !strings.Contains(steps[0].Observation, "ok") {
+ t.Fatalf("observation = %q, want the tool output", steps[0].Observation)
+ }
+}
+
+// An error carries the failure as its observation: a reader showing steps
+// should see why a call failed, not an empty result.
+func TestStepRecordCarriesTheFailureOnError(t *testing.T) {
+ var stream bytes.Buffer
+ writer := newEventWriter(&stream)
+ writer.busEvent(toolPartPayload(
+ "c2", "edit", "error", map[string]any{"filePath": "internal/x.go"}, "", "file does not exist",
+ ))
+
+ steps := streamSteps(t, stream.Bytes())
+ if len(steps) != 1 {
+ t.Fatalf("step records = %d, want 1", len(steps))
+ }
+ if steps[0].Command != "edit: internal/x.go" {
+ t.Fatalf("command = %q, want %q", steps[0].Command, "edit: internal/x.go")
+ }
+ if steps[0].Observation != "file does not exist" {
+ t.Fatalf("observation = %q, want the error", steps[0].Observation)
+ }
+}
+
+// A tool result can be a whole file. The observation is capped, and the cap is
+// applied on a rune boundary so the record always encodes.
+func TestStepObservationIsCappedAndStaysValidUTF8(t *testing.T) {
+ var stream bytes.Buffer
+ writer := newEventWriter(&stream)
+ // Three-byte runes, so a naive byte cut lands mid-rune.
+ output := strings.Repeat("→", stepObservationMax)
+ writer.busEvent(toolPartPayload(
+ "c3", "read", "completed", map[string]any{"filePath": "big.txt"}, output, "",
+ ))
+
+ steps := streamSteps(t, stream.Bytes())
+ if len(steps) != 1 {
+ t.Fatalf("step records = %d, want 1", len(steps))
+ }
+ if got := len(steps[0].Observation); got > stepObservationMax {
+ t.Fatalf("observation = %d bytes, want at most %d", got, stepObservationMax)
+ }
+ if !utf8.ValidString(steps[0].Observation) {
+ t.Fatal("observation was cut mid-rune and is not valid UTF-8")
+ }
+}
+
+// A tool whose input names none of the identifying keys still renders a label
+// rather than a bare tool name, so a new tool needs no change here.
+func TestStepCommandFallsBackToTheFirstStringInput(t *testing.T) {
+ record, ok := toolStepRecord(toolPartPayload(
+ "c4", "custom", "completed", map[string]any{"zeta": "last", "alpha": "first"}, "done", "",
+ ))
+ if !ok {
+ t.Fatal("a completed tool call was not recognised as a step")
+ }
+ if record.command != "custom: first" {
+ t.Fatalf("command = %q, want %q", record.command, "custom: first")
+ }
+}
+
+// Anything that is not a finished tool part is not a step.
+func TestNonToolPayloadsAreNotSteps(t *testing.T) {
+ if _, ok := toolStepRecord(assistantPayload("m1", "coder", 1, 2, 3, 0.01)); ok {
+ t.Fatal("an assistant message was read as a step")
+ }
+ if _, ok := toolStepRecord(bus.Payload{Type: "session.created"}); ok {
+ t.Fatal("a session event was read as a step")
+ }
+}
+
+// A FILE TOOL'S STEP CARRIES THE LINES IT ADDED AND REMOVED, read off the
+// metadata the tool wrote: write's and edit's counts for the one file, and
+// apply_patch's summed; every other tool carries none.
+func TestAFileToolsStepCarriesItsLinesAddedAndRemoved(t *testing.T) {
+ with := func(payload bus.Payload, metadata map[string]any) bus.Payload {
+ part := payload.Properties.(map[string]any)["part"].(map[string]any)
+ part["state"].(map[string]any)["metadata"] = metadata
+ return payload
+ }
+ for _, tc := range []struct {
+ name string
+ payload bus.Payload
+ added, removed int
+ none bool
+ }{
+ {"write", with(toolPartPayload("w", "write", "completed", map[string]any{"filePath": "a.go"}, "ok", ""),
+ map[string]any{"additions": float64(7), "deletions": float64(2)}), 7, 2, false},
+ {"edit", with(toolPartPayload("e", "edit", "completed", map[string]any{"filePath": "a.go"}, "ok", ""),
+ map[string]any{"filediff": map[string]any{"additions": float64(3), "deletions": float64(1)}}), 3, 1, false},
+ {"apply_patch", with(toolPartPayload("p", "apply_patch", "completed", map[string]any{"patchText": "x"}, "ok", ""),
+ map[string]any{"files": []any{map[string]any{"additions": float64(4), "deletions": float64(0)}, map[string]any{"additions": float64(1), "deletions": float64(5)}}}), 5, 5, false},
+ {"bash", with(toolPartPayload("b", "bash", "completed", map[string]any{"command": "ls"}, "ok", ""),
+ map[string]any{"exitCode": float64(0)}), 0, 0, true},
+ {"a failed edit", toolPartPayload("f", "edit", "error", map[string]any{"filePath": "a.go"}, "", "no match"), 0, 0, true},
+ } {
+ record, ok := toolStepRecord(tc.payload)
+ if !ok {
+ t.Fatalf("%s: no step record", tc.name)
+ }
+ if tc.none {
+ if record.added != nil || record.removed != nil {
+ t.Errorf("%s: lines %v/%v, want none", tc.name, record.added, record.removed)
+ }
+ continue
+ }
+ if record.added == nil || record.removed == nil || *record.added != tc.added || *record.removed != tc.removed {
+ t.Errorf("%s: lines %v/%v, want +%d -%d", tc.name, record.added, record.removed, tc.added, tc.removed)
+ }
+ }
+}
diff --git a/internal/seniordev/app/stop_test.go b/internal/seniordev/app/stop_test.go
new file mode 100644
index 0000000000..1e9f2d3b6f
--- /dev/null
+++ b/internal/seniordev/app/stop_test.go
@@ -0,0 +1,64 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// A STOP DURING THE FINAL CHECK IS NOT A FAILED CHECK. codeaf stops a run
+// with SIGTERM, which ends its context; if that lands while the project's own
+// tests are running, the test command is killed half way and leaves no exit
+// status. The run must not read that as the candidate failing: it ships the
+// frozen candidate and says nothing finished checking it, which is what is
+// true.
+func TestAStopDuringTheCheckShipsTheCandidateUnchecked(t *testing.T) {
+ runner, state, _, _ := soloPipeline(t)
+ started := filepath.Join(t.TempDir(), "tests-started")
+ makefile := "build:\n\t@true\n\ntest:\n\t@touch " + started + " && sleep 30\n"
+ if err := writeFile(filepath.Join(runner.workspace, "Makefile"), makefile); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, stop := context.WithCancel(context.Background())
+ defer stop()
+ go func() {
+ // The stop lands once the test command is running, not before the
+ // check could start, which is the case this test is about.
+ for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
+ if _, err := os.Stat(started); err == nil {
+ stop()
+ return
+ }
+ }
+ }()
+ outcome := &soloOutcome{Status: "fail"}
+ began := time.Now()
+ runner.soloShip(ctx, state, outcome, nil)
+ if took := time.Since(began); took > 20*time.Second {
+ t.Fatalf("ship took %s after the stop; the test command was not cut", took)
+ }
+ if _, err := os.Stat(started); err != nil {
+ t.Fatalf("the test command never started, so nothing was stopped mid-check: %v", err)
+ }
+ if outcome.Status != "pass-unverified" {
+ t.Fatalf("status = %q, want the candidate shipped unchecked (%#v)", outcome.Status, outcome.TerminalData)
+ }
+ if status, _ := soloResultStatus(*outcome); status != "pass" {
+ t.Fatalf("result status = %q, want pass: the frozen candidate stands", status)
+ }
+ if reason, _ := outcome.TerminalData["reason"].(string); !strings.Contains(reason, "stopped while") {
+ t.Fatalf("reason = %q, want it to say the check was stopped", reason)
+ }
+}
diff --git a/internal/seniordev/app/testsupport_test.go b/internal/seniordev/app/testsupport_test.go
new file mode 100644
index 0000000000..51edeafe23
--- /dev/null
+++ b/internal/seniordev/app/testsupport_test.go
@@ -0,0 +1,270 @@
+//go:build !windows
+
+package app
+
+// Shared fixtures for the pipeline tests.
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore"
+)
+
+// testAgentPrompt stands in for a baked agent document in tests that drive
+// the engine directly: a turn must carry an agent prompt to be composed.
+const testAgentPrompt = "test agent"
+
+// roundTripFunc is an http.RoundTripper made of one function, the stand-in
+// transport the engine tests answer model requests with. (It lived beside the
+// control-plane bridge's tests, which stayed behind with the bridge.)
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return roundTrip(request)
+}
+
+// testHost is the codeaf a run reports to in these tests: it serves the
+// workspace, the ceilings and the model API it was given, and keeps every
+// record the run wrote, in order.
+type testHost struct {
+ mu sync.Mutex
+ workspace string
+ ceilings delegate.Ceilings
+ api delegate.ModelAPI
+ hellos [][]string
+ stages []string
+ steps []string
+ terminals []delegate.Ending
+}
+
+func (host *testHost) Workspace() string { return host.workspace }
+func (host *testHost) Ceilings() delegate.Ceilings { return host.ceilings }
+func (host *testHost) Models() delegate.ModelAPI { return host.api }
+
+func (host *testHost) Hello(stages []string) {
+ host.mu.Lock()
+ defer host.mu.Unlock()
+ host.hellos = append(host.hellos, stages)
+}
+
+func (host *testHost) Stage(stage delegate.StageRecord) {
+ host.mu.Lock()
+ defer host.mu.Unlock()
+ host.stages = append(host.stages, stage.Stage+"/"+stage.Status)
+}
+
+func (host *testHost) Step(step delegate.StepRecord) {
+ host.mu.Lock()
+ defer host.mu.Unlock()
+ host.steps = append(host.steps, step.Command)
+}
+
+func (host *testHost) Terminal(end delegate.Ending) {
+ host.mu.Lock()
+ defer host.mu.Unlock()
+ host.terminals = append(host.terminals, end)
+}
+
+// testModelAPI stands in for the model API codeaf serves a run. The tests that
+// use it answer every request through their own transport, so nothing is sent
+// to its address; a backend without one has nowhere to send a request at all.
+var testModelAPI = delegate.ModelAPI{BaseURL: "http://model-api.invalid/v1", Token: "test-token"}
+
+// backendFunc is the stub model backend the pipeline tests run against.
+type backendFunc func(context.Context, turn) (turnResult, error)
+
+func (f backendFunc) Run(ctx context.Context, request turn) (turnResult, error) {
+ return f(ctx, request)
+}
+
+// guardWorkspace is a git repository with one commit, returning the workspace
+// and its base SHA. Tests of the submission protocol need a real base to diff
+// against, not a bare temp directory.
+func guardWorkspace(t *testing.T) (string, string) {
+ t.Helper()
+ workspace := gitWorkspace(t, map[string]string{"README.md": "base\n"})
+ return workspace, strings.TrimSpace(
+ gitOutput(context.Background(), workspace, "rev-parse", "HEAD"),
+ )
+}
+
+// testRepoWithEntrypoints is a git repository that also has runnable build and
+// test entrypoints, for tests that let verification actually execute something.
+func testRepoWithEntrypoints(t *testing.T) string {
+ t.Helper()
+ return gitWorkspace(t, map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ })
+}
+
+func gitWorkspace(t *testing.T, files map[string]string) string {
+ t.Helper()
+ workspace := t.TempDir()
+ if err := gitRun(workspace, "init", "-b", "main"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "config", "user.name", "senior-dev-test"); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "config", "user.email", "senior-dev@example.test"); err != nil {
+ t.Fatal(err)
+ }
+ names := make([]string, 0, len(files))
+ for name, content := range files {
+ if err := writeFile(filepath.Join(workspace, name), content); err != nil {
+ t.Fatal(err)
+ }
+ names = append(names, name)
+ }
+ if err := gitRun(workspace, append([]string{"add"}, names...)...); err != nil {
+ t.Fatal(err)
+ }
+ if err := gitRun(workspace, "commit", "-m", "base"); err != nil {
+ t.Fatal(err)
+ }
+ return workspace
+}
+
+// verifiedTestPipeline is a pipeline over a workspace whose verification the test
+// supplies directly, for cases that assert on how a verification RESULT is
+// interpreted rather than on running one.
+type discardWriter struct{}
+
+func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+// testTurn is the request shape the engine tests drive a turn with. It is the
+// subset of turn a caller has to supply; runTestTurn fills in the rest exactly
+// as soloTurn does, so a test measures the engine the run actually uses.
+type testTurn struct {
+ // SessionID drives the turn against an existing session instead of
+ // creating one. Tests of the durable transcript need a second runtime to
+ // land in the first one's session; production never needs this, because a
+ // run holds one session for its whole life.
+ SessionID string
+ Agent string
+ ProviderID string
+ ModelID string
+ Prompt string
+ Workspace string
+ SessionTitle string
+ ParentSessionID string
+}
+
+// runTestTurn creates a session and runs one turn through the same seams
+// soloTurn uses. The engine tests need an entry point that is not soloTurn
+// itself, which builds its prompt from the checklist and the pinned command
+// and so cannot be pointed at an arbitrary agent or model.
+func runTestTurn(
+ t *testing.T, runtime *runtimeAdapter, input testTurn,
+) (turnResult, error) {
+ t.Helper()
+ ctx := context.Background()
+ markdown, _ := baked.GetBakedAgent(input.Agent)
+ providerID, modelID := normalizeModelRef(input.ProviderID, input.ModelID)
+ sessionID := input.SessionID
+ if sessionID == "" {
+ info, err := runtime.createSession(ctx, sessioncore.CreateInput{
+ ParentID: input.ParentSessionID, Title: input.SessionTitle,
+ Agent: input.Agent, Directory: input.Workspace,
+ Model: sessionModel(providerID, modelID, ""),
+ })
+ if err != nil {
+ return turnResult{}, err
+ }
+ sessionID = info.ID
+ }
+ configured, err := runtime.configureTurn(turn{
+ SessionID: sessionID, ParentSessionID: input.ParentSessionID,
+ SessionTitle: input.SessionTitle, Agent: input.Agent,
+ AgentMarkdown: markdown, Workspace: input.Workspace,
+ ProviderID: providerID, ModelID: modelID, Prompt: input.Prompt,
+ })
+ if err != nil {
+ return turnResult{}, err
+ }
+ configured.ManageScratch = true
+ configured.SystemInstructions = runtime.registry.SystemInstructions(ctx)
+ configured.LoadInstructions = runtime.registry.SystemInstructions
+ configured.Tools = runtime.definitionsFor(
+ configured.ProviderID, configured.ModelID, input.Agent, nil,
+ )
+ configured.Execute = runtime.registry.Execute
+ configured.AfterAssistant = runtime.registry.ClearInstructionClaims
+ result, err := runtime.runTurn(ctx, configured)
+ runtime.addCost(result.CostUSD)
+ if result.SessionID == "" {
+ result.SessionID = sessionID
+ }
+ return result, err
+}
+
+type coderOnlyBackend struct {
+ mu sync.Mutex
+
+ onCoder func(call int, request turn) error
+
+ calls []turn
+ coderCalls int
+}
+
+func (backend *coderOnlyBackend) Run(
+ _ context.Context, request turn,
+) (turnResult, error) {
+ backend.mu.Lock()
+ backend.calls = append(backend.calls, request)
+ backend.mu.Unlock()
+
+ switch request.Agent {
+ case "coder":
+ backend.mu.Lock()
+ backend.coderCalls++
+ call := backend.coderCalls
+ backend.mu.Unlock()
+ if backend.onCoder != nil {
+ if err := backend.onCoder(call, request); err != nil {
+ return turnResult{}, err
+ }
+ }
+ return turnResult{Text: "coder completed"}, nil
+ default:
+ return turnResult{}, fmt.Errorf(
+ "test backend has no script for agent %q", request.Agent,
+ )
+ }
+}
+
+func (backend *coderOnlyBackend) count(agent string) int {
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ total := 0
+ for _, call := range backend.calls {
+ if call.Agent == agent {
+ total++
+ }
+ }
+ return total
+}
+
+func newRuntime(workspace string, client backend) *runtimeAdapter {
+ return newConfiguredRuntime(workspace, client, nil)
+}
+
+// Create opens a session for agent under parentID. Tests of the durable
+// transcript use it to hold a root session open across runtimes.
+func (runtime *runtimeAdapter) Create(
+ ctx context.Context, parentID string, agent string,
+) (string, error) {
+ info, err := runtime.createSession(ctx, sessioncore.CreateInput{
+ ParentID: parentID, Agent: agent, Title: agent, Directory: runtime.workspace,
+ })
+ return info.ID, err
+}
diff --git a/internal/seniordev/app/tier_test.go b/internal/seniordev/app/tier_test.go
new file mode 100644
index 0000000000..5600a66973
--- /dev/null
+++ b/internal/seniordev/app/tier_test.go
@@ -0,0 +1,176 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+ "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive"
+)
+
+// tierRouter builds the run's router from flag values exactly as a run does,
+// with a seed so the picks are reproducible.
+func tierRouter(args cliArgs) *adaptive.AdaptiveModelRouter {
+ seed := 17.0
+ return adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{
+ HighModels: configuredCandidates(args.High, adaptive.ModelTierHigh),
+ LowModels: configuredCandidates(args.Low, adaptive.ModelTierLow),
+ FrontierModels: configuredCandidates(args.Frontier, adaptive.ModelTierFrontier),
+ RandomSeed: &seed,
+ })
+}
+
+func agentTier(agent string) adaptive.ModelTier {
+ return adaptive.ModelTier(baked.TierFor(agent))
+}
+
+func TestLowPoolChangesWhereTheCompactionSummaryRoutes(t *testing.T) {
+ // The compaction summary is the low tier's consumer. Without --low it
+ // routes on --high; with --low it routes on the low pool, and the coder
+ // stays on --high either way.
+ without := tierRouter(cliArgs{High: "openrouter/qwen/high-only"})
+ with := tierRouter(cliArgs{
+ High: "openrouter/qwen/high-only", Low: "openrouter/qwen/cheap",
+ })
+ for _, test := range []struct {
+ router *adaptive.AdaptiveModelRouter
+ agent string
+ want string
+ tier adaptive.ModelTier
+ }{
+ {without, "coder", "openrouter/qwen/high-only", adaptive.ModelTierHigh},
+ {without, "compaction", "openrouter/qwen/high-only", adaptive.ModelTierHigh},
+ {with, "coder", "openrouter/qwen/high-only", adaptive.ModelTierHigh},
+ {with, "compaction", "openrouter/qwen/cheap", adaptive.ModelTierLow},
+ } {
+ choice := test.router.PickSync(test.agent, agentTier(test.agent))
+ if choice.Candidate.ID != test.want {
+ t.Errorf("%s routed to %q, want %q", test.agent, choice.Candidate.ID, test.want)
+ }
+ if choice.Tier != test.tier {
+ t.Errorf("%s routed on tier %q, want %q", test.agent, choice.Tier, test.tier)
+ }
+ test.router.Register(choice, 1, 10, nil)
+ }
+}
+
+func TestFrontierPoolIsOptional(t *testing.T) {
+ router := tierRouter(cliArgs{High: "openrouter/qwen/high-only"})
+ choice := router.PickSync("coder", adaptive.ModelTierFrontier)
+ if choice.Candidate.ID != "openrouter/qwen/high-only" {
+ t.Fatalf("frontier routed to %q with no frontier pool", choice.Candidate.ID)
+ }
+ router.Register(choice, 1, 10, nil)
+
+ configured := tierRouter(cliArgs{
+ High: "openrouter/qwen/high-only", Frontier: "openrouter/anthropic/big",
+ })
+ choice = configured.PickSync("coder", adaptive.ModelTierFrontier)
+ if choice.Candidate.ID != "openrouter/anthropic/big" {
+ t.Fatalf("frontier routed to %q, want the frontier pool", choice.Candidate.ID)
+ }
+}
+
+func TestASingleHighPoolRoutesEveryTierIdentically(t *testing.T) {
+ // The guarantee for a run that passes nothing but --high: the tier
+ // dimension must be invisible. `tiered` asks for each agent's configured
+ // tier, which degrades to high because no low or frontier pool exists;
+ // `flat` asks for high directly, which is the one code path a router with
+ // only a high pool had before tiers came back. Same seed, same pool, so
+ // every pick and every emitted event must agree field for field — the
+ // tier field itself excepted, since that is the field being added.
+ args := cliArgs{High: DefaultHighModels}
+ tiered, flat := tierRouter(args), tierRouter(args)
+ for round := 0; round < 6; round++ {
+ for _, agent := range []string{"coder", "compaction"} {
+ got := tiered.PickSync(agent, agentTier(agent))
+ want := flat.PickSync(agent, adaptive.ModelTierHigh)
+ if got.Tier != adaptive.ModelTierHigh {
+ t.Fatalf("round %d: %s routed on %q, want a degraded high", round, agent, got.Tier)
+ }
+ if got != want {
+ t.Fatalf("round %d: %s chose\n %+v\n want %+v", round, agent, got, want)
+ }
+ gotEvent := tiered.Register(got, 1.5, 120, nil)
+ wantEvent := flat.Register(want, 1.5, 120, nil)
+ if gotEvent.Tier != adaptive.ModelTierHigh {
+ t.Fatalf("round %d: event tier = %q", round, gotEvent.Tier)
+ }
+ gotEvent.Tier, wantEvent.Tier = "", ""
+ if gotEvent != wantEvent {
+ t.Fatalf("round %d: %s event\n %+v\n want %+v", round, agent, gotEvent, wantEvent)
+ }
+ }
+ }
+}
+
+func TestPoolResolverDegradesEmptyTiersToHigh(t *testing.T) {
+ resolver := poolResolver{high: []string{"a/one", "a/two"}}
+ for _, tier := range []baked.Tier{baked.TierHigh, baked.TierLow, baked.TierFrontier} {
+ if got := strings.Join(resolver.values(tier), ","); got != "a/one,a/two" {
+ t.Errorf("values(%q) = %q, want the high pool", tier, got)
+ }
+ }
+ resolver.low = []string{"b/cheap"}
+ resolver.frontier = []string{"c/big"}
+ for tier, want := range map[baked.Tier]string{
+ baked.TierHigh: "a/one,a/two",
+ baked.TierLow: "b/cheap",
+ baked.TierFrontier: "c/big",
+ } {
+ if got := strings.Join(resolver.values(tier), ","); got != want {
+ t.Errorf("values(%q) = %q, want %q", tier, got, want)
+ }
+ }
+}
+
+func TestRunContractRecordsThePoolEachTierRoutesOn(t *testing.T) {
+ workspace := gitWorkspace(t, map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ })
+ var events bytes.Buffer
+ runner := newPipeline(
+ cliArgs{High: "a/one,a/two", Low: "b/cheap"},
+ workspace,
+ pipelineDeps{
+ Backend: &soloScriptedBackend{},
+ Events: newEventWriter(&events), Notes: io.Discard,
+ },
+ )
+ defer runner.runtime.Close()
+ if _, err := runner.run(context.Background(), "Add the feature."); err != nil {
+ t.Fatal(err)
+ }
+ contract := map[string]any{}
+ for _, line := range bytes.Split(bytes.TrimSpace(events.Bytes()), []byte("\n")) {
+ var value event
+ if err := json.Unmarshal(line, &value); err != nil {
+ t.Fatalf("invalid NDJSON event %q: %v", line, err)
+ }
+ if value.Stage == "run-contract" {
+ contract = value.Data
+ }
+ }
+ for field, want := range map[string][]any{
+ "high_models": {"a/one", "a/two"},
+ "low_models": {"b/cheap"},
+ "frontier_models": {"a/one", "a/two"},
+ } {
+ got, _ := contract[field].([]any)
+ if len(got) != len(want) {
+ t.Fatalf("%s = %v, want %v", field, contract[field], want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("%s = %v, want %v", field, got, want)
+ }
+ }
+ }
+}
diff --git a/internal/seniordev/app/verification_deadtree.go b/internal/seniordev/app/verification_deadtree.go
new file mode 100644
index 0000000000..34a4cb926f
--- /dev/null
+++ b/internal/seniordev/app/verification_deadtree.go
@@ -0,0 +1,107 @@
+//go:build !windows
+
+package app
+
+import "strings"
+
+// Dead-tree detection for the unsubmitted-tree finalizer.
+//
+// A tree that can no longer be built or imported at all fails every test with
+// certainty: one collection error aborts a pytest run, one compile error runs
+// zero cargo tests. Such a tree is worth strictly less than an earlier
+// checkpoint, so the finalizer restores one when the run ends unsubmitted on
+// it (solo_finalize.go).
+//
+// A dead tree is a much stronger claim than a failing one -- degraded trees
+// usually still carry most of their value, so the burden of proof stays on
+// the restore. The tokens below are therefore SUITE-ABORT markers, not
+// failure markers: each one is printed only when the toolchain stopped before
+// running the suite. A failing test that merely mentions an ImportError in
+// its assertion output matches nothing here.
+var deadTreeTokens = []string{
+ // cargo: `error: could not compile ` -- zero tests ran.
+ "error: could not compile",
+ // go test's standard marker when a package fails to build.
+ "[build failed]",
+ // pytest's abort summary: `!! Interrupted: N error(s) during collection !!`
+ // (one import-time raise in a test file kills the whole run).
+ " during collection",
+ // pytest's per-file marker for the same condition.
+ "ERROR collecting",
+ // pytest aborts outright when a conftest fails to import.
+ "ImportError while loading",
+ // esbuild/mocha aborts before running any test when a TypeScript test file
+ // cannot be transformed.
+ "Exception during run: Error: Transform failed",
+}
+
+// suiteDeadOutput reports whether a FAILING verification command's full
+// output shows the suite aborted before running (as opposed to running and
+// failing).
+func suiteDeadOutput(output string) bool {
+ if output == "" {
+ return false
+ }
+ for _, token := range deadTreeTokens {
+ if strings.Contains(output, token) {
+ return true
+ }
+ }
+ return false
+}
+
+// safetyRegressionOutput is broader than suiteDeadOutput. Jest can continue
+// running unrelated suites after one changed TypeScript file fails to parse,
+// so that tree is not globally dead, but it is still not a coherent
+// candidate. The finalizer records it and withholds the coherent checkpoint;
+// the narrower dead-tree classifier remains the only one allowed to restore
+// an unsubmitted workspace to an earlier checkpoint.
+func safetyRegressionOutput(output string) bool {
+ if suiteDeadOutput(output) {
+ return true
+ }
+ lower := strings.ToLower(output)
+ return strings.Contains(lower, "test suite failed to run") &&
+ (strings.Contains(lower, "syntaxerror") ||
+ strings.Contains(lower, "unexpected token") ||
+ strings.Contains(lower, "transform failed"))
+}
+
+// verificationShowsDeadTree scans a completed verification's command
+// evidence for a failure that (a) is this run's own doing -- not a timeout --
+// and (b) carries a suite-abort signature. It returns the first such
+// command.
+func verificationShowsDeadTree(result projectVerificationResult) (string, bool) {
+ for _, command := range result.Commands {
+ evidence, ok := command.(map[string]any)
+ if !ok {
+ continue
+ }
+ exit, _ := evidence["exit"].(float64)
+ timedOut, _ := evidence["timedOut"].(bool)
+ dead, _ := evidence["suite_dead"].(bool)
+ if exit != 0 && !timedOut && dead {
+ cmd, _ := evidence["cmd"].(string)
+ return cmd, true
+ }
+ }
+ return "", false
+}
+
+func verificationShowsSafetyRegression(result projectVerificationResult) (string, bool) {
+ for _, command := range result.Commands {
+ evidence, ok := command.(map[string]any)
+ if !ok {
+ continue
+ }
+ exit, _ := evidence["exit"].(float64)
+ timedOut, _ := evidence["timedOut"].(bool)
+ unsafe, _ := evidence["safety_regression"].(bool)
+ dead, _ := evidence["suite_dead"].(bool)
+ if exit != 0 && !timedOut && (unsafe || dead) {
+ cmd, _ := evidence["cmd"].(string)
+ return cmd, true
+ }
+ }
+ return "", false
+}
diff --git a/internal/seniordev/app/verification_deadtree_test.go b/internal/seniordev/app/verification_deadtree_test.go
new file mode 100644
index 0000000000..0d08a7152d
--- /dev/null
+++ b/internal/seniordev/app/verification_deadtree_test.go
@@ -0,0 +1,95 @@
+//go:build !windows
+
+package app
+
+import "testing"
+
+func deadCommandEvidence(cmd string, extra map[string]any) map[string]any {
+ evidence := map[string]any{"cmd": cmd, "exit": float64(1), "suite_dead": true}
+ for key, value := range extra {
+ evidence[key] = value
+ }
+ return evidence
+}
+
+// The fixtures follow real toolchain output: each dead sample is the shape a
+// build tool or test runner prints when it stops before running the suite,
+// and each alive sample is a suite that ran and failed.
+func TestSuiteDeadOutputClassifier(t *testing.T) {
+ dead := []string{
+ // cargo build: a compile error, zero tests ran.
+ "error[E0063]: missing fields `alpha`, `beta`, `gamma` and 5 other fields\nerror: could not compile `widget` (bin \"widget\") due to 1 previous error",
+ // pytest: one import-time raise killed collection of the whole suite.
+ "!!!!!!!! Interrupted: 1 error during collection !!!!!!!!\n= 1 error in 1.73s =",
+ "ERROR collecting tests/unit/test_models.py",
+ "ImportError while loading conftest '/repo/tests/conftest.py'.",
+ "FAIL\tgithub.com/example/pkg [build failed]",
+ // esbuild aborted before mocha ran a test.
+ "Exception during run: Error: Transform failed with 1 error:\nref.test.js:12:9: ERROR: Expected \")\" but found \":\"",
+ }
+ for i, output := range dead {
+ if !suiteDeadOutput(output) {
+ t.Fatalf("dead fixture %d not classified:\n%s", i, output)
+ }
+ }
+ alive := []string{
+ "",
+ // a suite that RAN and failed -- degraded, not dead
+ "FAILED tests/test_docs.py::test_commands_are_documented\n= 3 failed, 240 passed in 41.02s =",
+ // a failing test that merely mentions an import error in its output
+ "E ImportError: optional dependency 'foo' is not installed\n= 1 failed, 99 passed =",
+ // an assertion failed after many tests ran.
+ "TypeCheckError: Type 'Widget' does not satisfy constraint\n= 1 failed, 126 passed =",
+ // network failures are not suite aborts
+ "npm error 403 403 Forbidden - GET https://registry.npmjs.org/some-package",
+ "go: downloading github.com/example/migrate v1.0.0",
+ }
+ for i, output := range alive {
+ if suiteDeadOutput(output) {
+ t.Fatalf("alive fixture %d wrongly classified dead:\n%s", i, output)
+ }
+ }
+}
+
+func TestVerificationShowsDeadTreeExcusesFailures(t *testing.T) {
+ excused := projectVerificationResult{Commands: []any{
+ deadCommandEvidence("make test", map[string]any{"timedOut": true}),
+ map[string]any{"cmd": "cargo build", "exit": float64(0), "suite_dead": true},
+ }}
+ if cmd, dead := verificationShowsDeadTree(excused); dead {
+ t.Fatalf("excused failures classified the tree dead via %q", cmd)
+ }
+ genuine := projectVerificationResult{Commands: []any{
+ map[string]any{"cmd": "go vet", "exit": float64(0)},
+ deadCommandEvidence("cargo build", nil),
+ }}
+ cmd, dead := verificationShowsDeadTree(genuine)
+ if !dead || cmd != "cargo build" {
+ t.Fatalf("genuine suite-abort not detected (cmd=%q dead=%v)", cmd, dead)
+ }
+}
+
+func TestSafetyRegressionRecognizesJestParseAbortWithoutCallingWholeTreeDead(t *testing.T) {
+ output := "FAIL tests/feature.test.js\nTest suite failed to run\n" +
+ "SyntaxError: Jest encountered an unexpected token\n590 passed"
+ if suiteDeadOutput(output) {
+ t.Fatal("a suite-local Jest parse failure was classified as a globally dead tree")
+ }
+ if !safetyRegressionOutput(output) {
+ t.Fatal("the Jest parse failure was not classified as a safety regression")
+ }
+ result := projectVerificationResult{Commands: []any{map[string]any{
+ "cmd": "npm test", "exit": float64(1), "safety_regression": true,
+ }}}
+ if command, unsafe := verificationShowsSafetyRegression(result); !unsafe || command != "npm test" {
+ t.Fatalf("safety regression = (%q, %v), want npm test, true", command, unsafe)
+ }
+}
+
+func TestRememberVerifiedTreeRetainsTheVerdictForFinalization(t *testing.T) {
+ runner := gitTestRepo(t)
+ runner.rememberVerifiedTree(verificationWith(1))
+ if runner.lastVerify == nil {
+ t.Fatal("verification was not retained for finalization")
+ }
+}
diff --git a/internal/seniordev/app/verification_timeout_test.go b/internal/seniordev/app/verification_timeout_test.go
new file mode 100644
index 0000000000..ebcc53c940
--- /dev/null
+++ b/internal/seniordev/app/verification_timeout_test.go
@@ -0,0 +1,171 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification"
+)
+
+// Contract for a verification entrypoint that hangs. Without it a run can
+// spend half its budget running the same suite several times, each attempt
+// killed at the 600s ceiling with byte-identical output, and then be told to
+// "rerun the exact command until it exits 0":
+//
+// - a hung entrypoint must be reported as HUNG, not as "exited -1", so the
+// model can tell an unfinished suite from a red one;
+// - the same command must not be re-executed against an unchanged tree — the
+// answer cannot differ and each attempt costs the full ceiling;
+// - a changed tree DOES earn a fresh attempt, because the agent may have
+// fixed the hang;
+// - a hang must never let the run go green.
+func newTimeoutWorkspace(t *testing.T) (workspace, marker string) {
+ t.Helper()
+ workspace = t.TempDir()
+ marker = filepath.Join(t.TempDir(), "executions")
+ if err := writeFile(filepath.Join(workspace, "go.mod"),
+ "module example.test/hang\n\ngo 1.23\n"); err != nil {
+ t.Fatal(err)
+ }
+ source := fmt.Sprintf(`package hang
+
+import (
+ "os"
+ "testing"
+)
+
+func TestHang(t *testing.T) {
+ file, err := os.OpenFile(%q, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil { t.Fatal(err) }
+ defer file.Close()
+ if _, err := file.WriteString("x"); err != nil { t.Fatal(err) }
+}
+`, marker)
+ if err := writeFile(filepath.Join(workspace, "hang_test.go"), source); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, "AGENTS.md"),
+ "Run `go build ./...` and `go test -count=1 ./...`.\n"); err != nil {
+ t.Fatal(err)
+ }
+ // worktreeFingerprint shells out to `git ls-files`, so the memo only has a
+ // tree identity to compare against inside a repository.
+ if out, err := exec.Command("git", "-C", workspace, "init", "-q").CombinedOutput(); err != nil {
+ t.Skipf("git unavailable: %v: %s", err, out)
+ }
+ return workspace, marker
+}
+
+func executionCount(t *testing.T, marker string) int {
+ t.Helper()
+ body, err := os.ReadFile(marker)
+ if os.IsNotExist(err) {
+ return 0
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(body)
+}
+
+func TestVerificationTimeoutIsNotRerunAgainstAnUnchangedTree(t *testing.T) {
+ workspace, marker := newTimeoutWorkspace(t)
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ plan := fullverification.Discover(workspace)
+ var testEntry *fullverification.Entrypoint
+ for _, entrypoint := range plan.Entrypoints {
+ if entrypoint.Kind == fullverification.KindTest {
+ found := entrypoint
+ testEntry = &found
+ break
+ }
+ }
+ if testEntry == nil {
+ t.Fatalf("no test entrypoint discovered from %#v", plan.Entrypoints)
+ }
+ fingerprint, ok := runner.worktreeFingerprint(context.Background())
+ if !ok {
+ t.Skip("worktree fingerprint unavailable in this environment")
+ }
+
+ // Stand in for a prior cycle that hung: the entrypoint is on record as
+ // having been killed at the ceiling against exactly this tree.
+ runner.verificationTimeouts = map[string]timedOutEntrypoint{
+ verificationMemoKey(*testEntry): {
+ Tail: "tests/test_server.py .\ncommand timed out after 600000ms",
+ Fingerprint: fingerprint,
+ HaveFinger: true,
+ },
+ }
+
+ result := runner.runProjectVerification(context.Background())
+
+ if got := executionCount(t, marker); got != 0 {
+ t.Errorf("test entrypoint executed %d time(s); a recorded hang against an "+
+ "unchanged tree must be replayed, not re-run at the full ceiling", got)
+ }
+ if !result.TimedOut {
+ t.Errorf("result.TimedOut = false; the replayed observation must stay a timeout")
+ }
+ if result.Failed == nil {
+ t.Fatalf("result.Failed = nil; a hung suite must not let the run go green")
+ }
+ if strings.Contains(result.Failure, "exited -1") {
+ t.Errorf("failure text reports an exit code for a command that never exited: %q", result.Failure)
+ }
+ if !strings.Contains(result.Failure, "hung") {
+ t.Errorf("failure text does not say the suite hung: %q", result.Failure)
+ }
+ // The evidence row must carry the distinction the prompt text now makes.
+ row, isMap := result.Commands[len(result.Commands)-1].(map[string]any)
+ if !isMap || row["timedOut"] != true {
+ t.Errorf("evidence row missing timedOut marker: %#v", result.Commands)
+ }
+}
+
+func TestVerificationTimeoutIsRetriedOnceTheTreeChanges(t *testing.T) {
+ workspace, marker := newTimeoutWorkspace(t)
+ runner := newPipeline(cliArgs{}, workspace, pipelineDeps{
+ Events: newEventWriter(io.Discard), Notes: io.Discard,
+ })
+ defer runner.runtime.Close()
+
+ plan := fullverification.Discover(workspace)
+ for _, entrypoint := range plan.Entrypoints {
+ if entrypoint.Kind != fullverification.KindTest {
+ continue
+ }
+ // A stale fingerprint: the agent has edited the tree since the hang, so
+ // the command deserves a fresh attempt.
+ runner.verificationTimeouts = map[string]timedOutEntrypoint{
+ verificationMemoKey(entrypoint): {
+ Tail: "command timed out after 600000ms",
+ Fingerprint: "stale-fingerprint-from-before-the-fix", HaveFinger: true,
+ },
+ }
+ }
+
+ result := runner.runProjectVerification(context.Background())
+
+ if got := executionCount(t, marker); got == 0 {
+ t.Errorf("test entrypoint never ran; a changed tree must earn a fresh attempt")
+ }
+ if result.TimedOut {
+ t.Errorf("result.TimedOut = true for a suite that completed")
+ }
+ if result.Failed != nil {
+ t.Errorf("green verification reported a failure: %#v", result.Failed)
+ }
+}
diff --git a/internal/seniordev/app/workspace_git.go b/internal/seniordev/app/workspace_git.go
new file mode 100644
index 0000000000..f3f132a384
--- /dev/null
+++ b/internal/seniordev/app/workspace_git.go
@@ -0,0 +1,52 @@
+//go:build !windows
+
+package app
+
+// Git helpers shared by the submit freeze, the ship decision and the
+// unsubmitted-tree finalizer.
+
+// countFailingEntrypoints counts the entrypoints whose command exited
+// non-zero.
+func countFailingEntrypoints(result projectVerificationResult) int {
+ failing := 0
+ for _, command := range result.Commands {
+ evidence, ok := command.(map[string]any)
+ if !ok {
+ continue
+ }
+ if exit, _ := evidence["exit"].(float64); exit != 0 {
+ failing++
+ }
+ }
+ return failing
+}
+
+// workspaceGit runs git in the workspace with the committer identity pinned,
+// the same way eager-commit does. Without it `commit-tree` dies with "Author
+// identity unknown" in a container that has no git config -- and commit-tree
+// is how submit records the frozen candidate, so the run could not submit at
+// all. The identity flags are `-c` overrides, which git ranks below
+// GIT_COMMITTER_*/GIT_AUTHOR_*, so an environment that sets those still wins.
+// currentTreeSHA identifies the working tree as it stands. It is a thin name
+// for the recorder's promise, kept because the run reads better saying what it
+// wants than naming the thing that provides it.
+func (runner *pipeline) currentTreeSHA() (string, error) {
+ return runner.recorder.Snapshot()
+}
+
+// rememberVerifiedTree caches the last completed full verification against
+// the git tree it measured, so the finalizer can consult the last verdict on
+// an unchanged tree without re-verifying. The runs that need the dead-tree
+// check end with no wall left to verify anything.
+func (runner *pipeline) rememberVerifiedTree(result projectVerificationResult) {
+ if result.TimedOut || len(result.Commands) == 0 {
+ return
+ }
+ treeSHA, err := runner.currentTreeSHA()
+ if err != nil {
+ return
+ }
+ remembered := result
+ runner.lastVerify = &remembered
+ runner.lastVerifyTreeSHA = treeSHA
+}
diff --git a/internal/seniordev/app/workspace_recorder.go b/internal/seniordev/app/workspace_recorder.go
new file mode 100644
index 0000000000..f5feef10e5
--- /dev/null
+++ b/internal/seniordev/app/workspace_recorder.go
@@ -0,0 +1,105 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+)
+
+// workspaceRecorder is how a run identifies, compares, freezes and restores
+// the workspace tree. The run's logic is written against this and never against
+// git: git is one way to keep these promises, not the only one.
+//
+// Identifiers (the strings returned by Base, Snapshot and Record) are opaque.
+// The run passes them back in and compares them for equality; it never parses
+// them. Under the git recorder they are object names, which is why they read
+// like SHAs in the event stream.
+type workspaceRecorder interface {
+ // Kind names the recorder on the run contract: "git" or "snapshot".
+ Kind() string
+
+ // Prepare checks the workspace is usable and arranges for senior-dev's own
+ // artifacts to stay out of the answer. It runs once, before the base is
+ // resolved, and its error refuses the run.
+ Prepare(ctx context.Context) error
+
+ // Base identifies the tree the run starts from. Everything the run reports
+ // as changed is changed relative to this.
+ Base(ctx context.Context) (string, error)
+
+ // Snapshot identifies the tree as it stands right now, including files no
+ // one has committed or added. Two identical trees give the same identifier
+ // and two different trees do not.
+ Snapshot() (string, error)
+
+ // Record captures the tree named by treeID as something Restore can bring
+ // back, and returns a handle to it. label is human-readable provenance.
+ Record(treeID, label string) (string, error)
+
+ // Publish makes a recorded handle reachable from outside this process under
+ // a stable name, so a run killed between recording and finalizing still has
+ // something to recover. Best-effort: a failure is noted, never fatal.
+ Publish(name, handle string) error
+
+ // Restore makes the working tree the one Record captured, and proves it by
+ // re-identifying the result. wantTree is that proof; a mismatch is an error.
+ Restore(handle, wantTree string) error
+
+ // BaseTree resolves a base identifier from Base to the tree identifier it
+ // names, so a base can be used as a restore target of last resort. ok is
+ // false when the base cannot be resolved, which is not an error.
+ BaseTree(base string) (treeID string, ok bool)
+
+ // Change compares the working tree against a base identifier, excluding
+ // senior-dev's own artifacts. This is what the submit gate consults.
+ Change(base string) (soloTreeChange, error)
+
+ // ListPaths enumerates every path belonging to the tree, ignores honoured,
+ // sorted. It gives up rather than reading an unbounded tree: overBudget
+ // reports that the listing exceeded maxBytes.
+ ListPaths(ctx context.Context, maxBytes int) (
+ paths []string, consumed int, overBudget bool, err error,
+ )
+
+ // Summary describes the run's final diff against base for the patch-summary
+ // event. It is observational: nothing in the run acts on it, so a recorder
+ // that cannot produce a field omits it rather than failing. The returned
+ // status is the event's status.
+ Summary(ctx context.Context, base string) (data map[string]any, status string)
+
+ // CommitsOnWrite reports whether the recorder wants a checkpoint taken
+ // after each file write. Git does, because a per-write commit is nearly
+ // free; copying the tree after every edit would not be.
+ CommitsOnWrite() bool
+}
+
+// newWorkspaceRecorder picks the recorder for a run. Git is used IF IT IS
+// THERE: a workspace inside a repository with a commit gets the git recorder,
+// and anything else — a plain folder, a repository with no commit yet, a
+// broken .git, a machine with no git — gets the snapshot recorder, exactly as
+// --in-place would. A run never ends for want of git.
+//
+// Inference only ever steps DOWN from git, never up. --in-place still forces
+// the snapshot recorder inside a real repository, because a run there that
+// must not touch the history is indistinguishable, from the filesystem, from
+// one that should; that choice stays the caller's (codeaf makes it for a
+// repository rooted at the home folder).
+func newWorkspaceRecorder(
+ args cliArgs, workspace string, note func(string),
+) workspaceRecorder {
+ if args.InPlace || !hasGitHistory(workspace) {
+ return newSnapshotRecorder(workspace, note)
+ }
+ return newGitRecorder(workspace, note)
+}
+
+// hasGitHistory reports whether the workspace is inside a git work tree whose
+// HEAD is a commit — the two things the git recorder cannot start without
+// (Prepare's work tree, Base's commit). No git on PATH answers false.
+func hasGitHistory(workspace string) bool {
+ ctx := context.Background()
+ if gitOutput(ctx, workspace, "rev-parse", "--is-inside-work-tree") != "true" {
+ return false
+ }
+ return gitOutput(ctx, workspace, "rev-parse", "--verify", "--quiet", "HEAD^{commit}") != ""
+}
diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go
new file mode 100644
index 0000000000..52867ba8b0
--- /dev/null
+++ b/internal/seniordev/app/workspace_recorder_git.go
@@ -0,0 +1,346 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/util"
+)
+
+// gitRecorder keeps the workspaceRecorder promises with git. It is the default
+// and the only recorder that leaves the run's work in the repository's own
+// history.
+type gitRecorder struct {
+ workspace string
+ note func(string)
+}
+
+func newGitRecorder(workspace string, note func(string)) *gitRecorder {
+ return &gitRecorder{workspace: workspace, note: note}
+}
+
+func (recorder *gitRecorder) Kind() string { return "git" }
+func (recorder *gitRecorder) CommitsOnWrite() bool { return true }
+
+// git runs a git command in the workspace and returns its trimmed output.
+func (recorder *gitRecorder) git(args ...string) (string, error) {
+ argv := util.GitArgv(args...)
+ cmd := exec.Command(argv[0], argv[1:]...)
+ cmd.Dir = recorder.workspace
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ // The logical args, not the identity flags: the message is read by a
+ // model deciding what to fix, and the flags are never the problem.
+ return "", fmt.Errorf(
+ "git %s: %v: %s",
+ strings.Join(args, " "), err, strings.TrimSpace(string(out)),
+ )
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func (recorder *gitRecorder) Prepare(ctx context.Context) error {
+ if gitOutput(ctx, recorder.workspace, "rev-parse", "--show-toplevel") == "" {
+ // Unreachable in practice: newWorkspaceRecorder picks this recorder
+ // only after reading a work tree with a commit. It stays for a folder
+ // whose repository vanished between that reading and this one.
+ return fmt.Errorf("workspace is not a git repository: %s; run with --in-place to work in a plain folder", recorder.workspace)
+ }
+ // Exclude senior-dev's own artifacts on the workspace at bootstrap
+ // (non-fatal): without it the run's commits sweep senior-dev's bookkeeping
+ // into the repository history, and the final patch carries files the
+ // request never asked for.
+ if _, err := util.EnsureSeniorDevExcluded(ctx, recorder.workspace); err != nil {
+ recorder.note("[senior-dev] ensureSeniorDevExcluded failed (non-fatal): " + err.Error() + "\n")
+ }
+ return nil
+}
+
+func (recorder *gitRecorder) Base(ctx context.Context) (string, error) {
+ baseSHA := gitOutput(ctx, recorder.workspace, "rev-parse", "HEAD")
+ if baseSHA == "" {
+ return "", fmt.Errorf("senior-dev run requires a git repository with at least one commit, or --in-place")
+ }
+ resolved := gitOutput(ctx, recorder.workspace, "rev-parse", "--verify", baseSHA+"^{commit}")
+ if resolved == "" {
+ return "", fmt.Errorf("base commit %q is not available in the workspace", baseSHA)
+ }
+ return resolved, nil
+}
+
+// Snapshot captures the full working tree (tracked and untracked, minus
+// ignored) as a git tree object, through a temporary index so the real index
+// and working tree are untouched.
+func (recorder *gitRecorder) Snapshot() (string, error) {
+ gitDir, err := recorder.git("rev-parse", "--git-dir")
+ if err != nil {
+ return "", err
+ }
+ if !filepath.IsAbs(gitDir) {
+ gitDir = filepath.Join(recorder.workspace, gitDir)
+ }
+ tmp, err := os.CreateTemp(gitDir, "senior-dev-tree-index-*")
+ if err != nil {
+ return "", fmt.Errorf("create temporary git index: %w", err)
+ }
+ tmpIndex := tmp.Name()
+ if closeErr := tmp.Close(); closeErr != nil {
+ _ = os.Remove(tmpIndex)
+ return "", fmt.Errorf("close temporary git index: %w", closeErr)
+ }
+ // GIT_INDEX_FILE requires either a valid index or no file. CreateTemp gives
+ // us a collision-free name; remove the empty file before asking Git to
+ // initialize it from HEAD. Loading HEAD is essential: git add -A against an
+ // empty index omits tracked-but-ignored files and invents phantom deletions.
+ if err := os.Remove(tmpIndex); err != nil {
+ return "", fmt.Errorf("prepare temporary git index: %w", err)
+ }
+ defer os.Remove(tmpIndex)
+ env := append(os.Environ(), "GIT_INDEX_FILE="+tmpIndex)
+ read := exec.Command("git", "read-tree", "HEAD")
+ read.Dir, read.Env = recorder.workspace, env
+ if out, err := read.CombinedOutput(); err != nil {
+ return "", fmt.Errorf("git read-tree HEAD: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ add := exec.Command("git", "add", "-A", ".")
+ add.Dir, add.Env = recorder.workspace, env
+ if out, err := add.CombinedOutput(); err != nil {
+ return "", fmt.Errorf("git add -A: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ write := exec.Command("git", "write-tree")
+ write.Dir, write.Env = recorder.workspace, env
+ out, err := write.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("git write-tree: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// Record writes a commit object for an already-written tree without moving
+// HEAD, the index, or the working tree. The commit exists so the candidate can
+// be restored later by a single git command even if the run dies before
+// finalize.
+func (recorder *gitRecorder) Record(treeID, label string) (string, error) {
+ parent, parentErr := recorder.git("rev-parse", "HEAD")
+ args := []string{"commit-tree", treeID, "-m", label}
+ if parentErr == nil && parent != "" {
+ args = []string{"commit-tree", treeID, "-p", parent, "-m", label}
+ }
+ return recorder.git(args...)
+}
+
+func (recorder *gitRecorder) Publish(name, handle string) error {
+ _, err := recorder.git("update-ref", name, handle)
+ return err
+}
+
+// Restore makes the working tree byte-identical to a recorded commit's tree,
+// and proves it did by re-hashing.
+//
+// `checkout --force -- .` alone is OVERLAY checkout: it writes the
+// commit's files and deletes nothing. Every file the model writes is tracked
+// (eager-commit), so a file ADDED after the checkpoint -- probe debris is the
+// common case -- survives both the checkout and a `clean -fd`, and the
+// "restored" tree does not match the checkpoint. `--no-overlay` would fix it
+// but needs git >= 2.23, which not every image has; resetting the index to
+// the commit first makes the extras untracked, so the same old-git `clean`
+// removes them.
+func (recorder *gitRecorder) Restore(handle, wantTree string) error {
+ if _, err := recorder.git("checkout", "--force", handle, "--", "."); err != nil {
+ return err
+ }
+ if _, err := recorder.git("reset", "-q", handle, "--", "."); err != nil {
+ return err
+ }
+ if _, err := recorder.git("clean", "-fd"); err != nil {
+ return err
+ }
+ actual, err := recorder.Snapshot()
+ if err != nil {
+ return err
+ }
+ if actual != wantTree {
+ return fmt.Errorf(
+ "restored tree %s, want %s", shortSHA(actual), shortSHA(wantTree),
+ )
+ }
+ return nil
+}
+
+func (recorder *gitRecorder) BaseTree(base string) (string, bool) {
+ tree, err := recorder.git("rev-parse", base+"^{tree}")
+ if err != nil || tree == "" {
+ return "", false
+ }
+ return tree, true
+}
+
+// Change compares the workspace against the base commit's tree, ignoring
+// senior-dev's own artifacts.
+//
+// It deliberately does not use `git diff ` against the working copy,
+// which reports only tracked changes. A run whose whole deliverable is a new
+// file -- which is most of them -- produces an empty `git diff` while having
+// changed everything that matters, so diffing that way would refuse exactly
+// the submissions worth accepting. Snapshot stages everything through a
+// temporary index, so comparing against that tree sees new files the way a
+// diff of the final tree will.
+func (recorder *gitRecorder) Change(base string) (soloTreeChange, error) {
+ treeSHA, err := recorder.Snapshot()
+ if err != nil {
+ return soloTreeChange{}, err
+ }
+ change := soloTreeChange{treeSHA: treeSHA}
+ baseTree, ok := recorder.BaseTree(base)
+ if !ok {
+ // No resolvable base: any tree at all is a change, and refusing to
+ // submit because we cannot name the starting point would be worse than
+ // accepting one we cannot size.
+ change.changed = true
+ return change, nil
+ }
+ diffArgs := func(extra ...string) []string {
+ args := append([]string{"diff"}, extra...)
+ args = append(args, baseTree, treeSHA, "--")
+ return append(args, seniorDevArtifactPathspecs...)
+ }
+ names, err := recorder.git(diffArgs("--name-only")...)
+ if err != nil {
+ return change, err
+ }
+ change.files = len(nonEmptyLines(names))
+ change.changed = change.files > 0
+ if !change.changed {
+ return change, nil
+ }
+ if patch, err := recorder.git(diffArgs()...); err == nil {
+ change.patch = patch
+ }
+ return change, nil
+}
+
+func (recorder *gitRecorder) ListPaths(
+ ctx context.Context, maxBytes int,
+) ([]string, int, bool, error) {
+ command := exec.CommandContext(
+ ctx, "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard",
+ )
+ command.Dir = recorder.workspace
+ stdout, err := command.StdoutPipe()
+ if err != nil {
+ return nil, 0, false, err
+ }
+ if err := command.Start(); err != nil {
+ return nil, 0, false, err
+ }
+ raw, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxBytes)+1))
+ if len(raw) > maxBytes {
+ _ = command.Process.Kill()
+ _ = command.Wait()
+ return nil, len(raw), true, nil
+ }
+ waitErr := command.Wait()
+ if readErr != nil {
+ return nil, len(raw), false, readErr
+ }
+ if waitErr != nil {
+ return nil, len(raw), false, waitErr
+ }
+ paths := strings.Split(string(raw), "\x00")
+ if len(paths) > 0 && paths[len(paths)-1] == "" {
+ paths = paths[:len(paths)-1]
+ }
+ sort.Strings(paths)
+ return paths, len(raw), false, nil
+}
+
+// Summary records the shape of the run's final diff against the base commit --
+// files, line counts, binaries, patch bytes, untracked files.
+func (recorder *gitRecorder) Summary(
+ ctx context.Context, base string,
+) (map[string]any, string) {
+ workspace := recorder.workspace
+ data := map[string]any{"base_sha": base}
+ if head := gitOutput(ctx, workspace, "rev-parse", "HEAD"); head != "" {
+ data["head_sha"] = head
+ }
+ nameOutput := gitOutput(ctx, workspace, "diff", "--name-only", "--no-renames", base, "--")
+ files := 0
+ if strings.TrimSpace(nameOutput) != "" {
+ files = len(strings.Split(strings.TrimSpace(nameOutput), "\n"))
+ }
+ data["files"] = files
+
+ additions, deletions, binaries := int64(0), int64(0), 0
+ for _, line := range strings.Split(
+ gitOutput(ctx, workspace, "diff", "--numstat", "--no-renames", base, "--"), "\n",
+ ) {
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ if fields[0] == "-" || fields[1] == "-" {
+ binaries++
+ continue
+ }
+ if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
+ additions += value
+ }
+ if value, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
+ deletions += value
+ }
+ }
+ data["additions"], data["deletions"], data["binary_files"] = additions, deletions, binaries
+
+ var patchBytes countingWriter
+ var diffError bytes.Buffer
+ command := exec.CommandContext(ctx, "git", "diff", "--binary", "--no-renames", base, "--")
+ command.Dir, command.Stdout, command.Stderr = workspace, &patchBytes, &diffError
+ status := "completed"
+ if err := command.Run(); err != nil {
+ status = "error"
+ data["error"] = strings.TrimSpace(diffError.String())
+ } else {
+ data["patch_bytes"] = int64(patchBytes)
+ }
+ untracked := gitOutput(ctx, workspace, "ls-files", "--others", "--exclude-standard")
+ if strings.TrimSpace(untracked) != "" {
+ data["untracked_files"] = len(strings.Split(strings.TrimSpace(untracked), "\n"))
+ } else {
+ data["untracked_files"] = 0
+ }
+ return data, status
+}
+
+// gitStatusFindings reports an unclean index as a landing finding. It exists
+// only under the git recorder: the advice it gives -- commit before verifying
+// -- is meaningless where nothing commits.
+func (recorder *gitRecorder) statusFindings() []string {
+ status, err := recorder.git("status", "--porcelain")
+ if err != nil {
+ return nil
+ }
+ entries := nonEmptyLines(status)
+ if len(entries) == 0 {
+ return nil
+ }
+ return []string{fmt.Sprintf(
+ "git status is not clean (%d uncommitted entr%s) — the pinned command must pass "+
+ "on the COMMITTED tree, so commit before verifying",
+ len(entries), plural(len(entries), "y", "ies"),
+ )}
+}
+
+// summaryTimeout bounds the observational patch summary.
+const summaryTimeout = 15 * time.Second
diff --git a/internal/seniordev/app/workspace_recorder_snapshot.go b/internal/seniordev/app/workspace_recorder_snapshot.go
new file mode 100644
index 0000000000..414aed6146
--- /dev/null
+++ b/internal/seniordev/app/workspace_recorder_snapshot.go
@@ -0,0 +1,515 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// snapshotRecorder keeps the workspaceRecorder promises without git. It edits
+// the workspace in place, takes no locks on it, writes nothing into it beyond
+// what the model writes, and keeps its own copies of the tree outside it.
+//
+// Identifiers are content addresses: the SHA-256 of a manifest of every path,
+// mode and content hash in the tree. Two identical trees therefore have the
+// same identifier and two different trees do not, which is the only property
+// the run relies on.
+type snapshotRecorder struct {
+ workspace string
+ note func(string)
+
+ mu sync.Mutex
+ store string // lazily created; "" until the first snapshot is kept
+ // published maps a name to the handle last published under it, so a
+ // restore target survives in-process even where nothing writes a ref.
+ published map[string]string
+}
+
+func newSnapshotRecorder(workspace string, note func(string)) *snapshotRecorder {
+ return &snapshotRecorder{
+ workspace: workspace, note: note, published: map[string]string{},
+ }
+}
+
+func (recorder *snapshotRecorder) Kind() string { return "snapshot" }
+func (recorder *snapshotRecorder) CommitsOnWrite() bool { return false }
+
+func (recorder *snapshotRecorder) Prepare(ctx context.Context) error {
+ if _, err := os.Stat(recorder.workspace); err != nil {
+ return fmt.Errorf("workspace is not readable: %w", err)
+ }
+ // Nothing to arrange. The artifact exclusion the git recorder writes into
+ // .git/info/exclude is unnecessary here: the walker skips .senior-dev/ by
+ // construction, so the artifacts cannot enter a snapshot in the first
+ // place.
+ return nil
+}
+
+// Base is the tree as the run found it. There is no commit to name, so the
+// starting tree names itself, and the run's "unchanged since the start" test
+// is an identifier comparison exactly as it is under git.
+func (recorder *snapshotRecorder) Base(ctx context.Context) (string, error) {
+ return recorder.Snapshot()
+}
+
+func (recorder *snapshotRecorder) Snapshot() (string, error) {
+ entries, err := recorder.walk()
+ if err != nil {
+ return "", err
+ }
+ return manifestID(entries), nil
+}
+
+// Record copies the working tree into the store under its own identifier. A
+// tree already stored is not copied again: identical identifiers mean
+// identical content, so the first copy is as good as a second.
+func (recorder *snapshotRecorder) Record(treeID, label string) (string, error) {
+ store, err := recorder.ensureStore()
+ if err != nil {
+ return "", err
+ }
+ target := filepath.Join(store, treeID)
+ if _, err := os.Stat(target); err == nil {
+ return treeID, nil
+ }
+ entries, err := recorder.walk()
+ if err != nil {
+ return "", err
+ }
+ if actual := manifestID(entries); actual != treeID {
+ return "", fmt.Errorf(
+ "tree changed while recording it: %s, want %s",
+ shortSHA(actual), shortSHA(treeID),
+ )
+ }
+ // Assembled beside the final name and renamed into place, so a crash
+ // mid-copy cannot leave a half-tree that a later Stat would accept.
+ staging, err := os.MkdirTemp(store, "staging-*")
+ if err != nil {
+ return "", fmt.Errorf("create snapshot staging directory: %w", err)
+ }
+ defer os.RemoveAll(staging)
+ for _, entry := range entries {
+ source := filepath.Join(recorder.workspace, filepath.FromSlash(entry.path))
+ destination := filepath.Join(staging, filepath.FromSlash(entry.path))
+ if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
+ return "", err
+ }
+ if err := copyFile(source, destination, entry.mode); err != nil {
+ return "", err
+ }
+ }
+ if err := os.WriteFile(
+ filepath.Join(staging, ".senior-dev-label"), []byte(label+"\n"), 0o644,
+ ); err != nil {
+ return "", err
+ }
+ if err := os.Rename(staging, target); err != nil {
+ // Another Record of the same tree won the race; its copy is identical.
+ if _, statErr := os.Stat(target); statErr == nil {
+ return treeID, nil
+ }
+ return "", fmt.Errorf("store snapshot: %w", err)
+ }
+ return treeID, nil
+}
+
+// Publish records the name in memory. There is no repository to hang a ref on,
+// so unlike the git recorder this does not survive the process -- which is
+// why the interface calls it best-effort and nothing depends on it.
+func (recorder *snapshotRecorder) Publish(name, handle string) error {
+ recorder.mu.Lock()
+ defer recorder.mu.Unlock()
+ recorder.published[name] = handle
+ return nil
+}
+
+// Restore makes the working tree the recorded one: every path the tree has now
+// and the snapshot does not is removed, every path the snapshot has is written,
+// and the result is re-identified as proof.
+func (recorder *snapshotRecorder) Restore(handle, wantTree string) error {
+ recorder.mu.Lock()
+ store := recorder.store
+ recorder.mu.Unlock()
+ if store == "" {
+ return fmt.Errorf("no snapshot store: nothing was recorded")
+ }
+ source := filepath.Join(store, handle)
+ if _, err := os.Stat(source); err != nil {
+ return fmt.Errorf("snapshot %s is not in the store: %w", shortSHA(handle), err)
+ }
+ wanted, err := walkTree(source, false)
+ if err != nil {
+ return err
+ }
+ wantedPaths := map[string]struct{}{}
+ for _, entry := range wanted {
+ wantedPaths[entry.path] = struct{}{}
+ }
+ current, err := recorder.walk()
+ if err != nil {
+ return err
+ }
+ // Remove first: a path that is a file in the snapshot and a directory now
+ // (or the reverse) cannot be written over in place.
+ for _, entry := range current {
+ if _, keep := wantedPaths[entry.path]; keep {
+ continue
+ }
+ if err := os.Remove(
+ filepath.Join(recorder.workspace, filepath.FromSlash(entry.path)),
+ ); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ for _, entry := range wanted {
+ destination := filepath.Join(recorder.workspace, filepath.FromSlash(entry.path))
+ if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
+ return err
+ }
+ if err := copyFile(
+ filepath.Join(source, filepath.FromSlash(entry.path)), destination, entry.mode,
+ ); err != nil {
+ return err
+ }
+ }
+ recorder.pruneEmptyDirs()
+ actual, err := recorder.Snapshot()
+ if err != nil {
+ return err
+ }
+ if actual != wantTree {
+ return fmt.Errorf(
+ "restored tree %s, want %s", shortSHA(actual), shortSHA(wantTree),
+ )
+ }
+ return nil
+}
+
+// BaseTree is the identity function: a snapshot base IS a tree identifier,
+// where a git base is a commit that has to be resolved to one.
+func (recorder *snapshotRecorder) BaseTree(base string) (string, bool) {
+ if base == "" {
+ return "", false
+ }
+ return base, true
+}
+
+func (recorder *snapshotRecorder) Change(base string) (soloTreeChange, error) {
+ entries, err := recorder.walk()
+ if err != nil {
+ return soloTreeChange{}, err
+ }
+ change := soloTreeChange{treeSHA: manifestID(entries)}
+ recorder.mu.Lock()
+ store := recorder.store
+ recorder.mu.Unlock()
+ if base == "" || store == "" {
+ // Nothing to compare against: any tree at all is a change, and
+ // refusing to submit because we cannot name the starting point would
+ // be worse than accepting one we cannot size.
+ change.changed = true
+ return change, nil
+ }
+ baseDir := filepath.Join(store, base)
+ baseEntries, err := walkTree(baseDir, false)
+ if err != nil {
+ change.changed = true
+ return change, nil
+ }
+ change.files = countChangedPaths(baseEntries, entries)
+ change.changed = change.files > 0
+ // No patch text: producing one needs a diff algorithm this recorder does
+ // not carry. The field is advisory -- the submit gate reads `changed` and
+ // `files` -- so it is left empty rather than faked.
+ return change, nil
+}
+
+func (recorder *snapshotRecorder) ListPaths(
+ ctx context.Context, maxBytes int,
+) ([]string, int, bool, error) {
+ entries, err := recorder.walk()
+ if err != nil {
+ return nil, 0, false, err
+ }
+ paths := make([]string, 0, len(entries))
+ consumed := 0
+ for _, entry := range entries {
+ consumed += len(entry.path) + 1
+ if consumed > maxBytes {
+ return nil, consumed, true, nil
+ }
+ paths = append(paths, entry.path)
+ }
+ sort.Strings(paths)
+ return paths, consumed, false, nil
+}
+
+// Summary reports what it can measure without a diff algorithm: which paths
+// differ from the base and how many bytes they hold. `additions` and
+// `deletions` are absent rather than guessed -- the event contract marks them
+// optional for exactly this reason.
+func (recorder *snapshotRecorder) Summary(
+ ctx context.Context, base string,
+) (map[string]any, string) {
+ data := map[string]any{"base_sha": base}
+ entries, err := recorder.walk()
+ if err != nil {
+ data["error"] = err.Error()
+ return data, "error"
+ }
+ recorder.mu.Lock()
+ store := recorder.store
+ recorder.mu.Unlock()
+ if store == "" || base == "" {
+ data["files"] = len(entries)
+ data["untracked_files"] = 0
+ return data, "completed"
+ }
+ baseEntries, err := walkTree(filepath.Join(store, base), false)
+ if err != nil {
+ data["error"] = err.Error()
+ return data, "error"
+ }
+ data["files"] = countChangedPaths(baseEntries, entries)
+ data["binary_files"] = 0
+ data["untracked_files"] = 0
+ changedBytes := int64(0)
+ baseByPath := map[string]treeEntry{}
+ for _, entry := range baseEntries {
+ baseByPath[entry.path] = entry
+ }
+ for _, entry := range entries {
+ if previous, ok := baseByPath[entry.path]; !ok || previous.hash != entry.hash {
+ changedBytes += entry.size
+ }
+ }
+ data["patch_bytes"] = changedBytes
+ return data, "completed"
+}
+
+// ── the tree walk ────────────────────────────────────────────────────
+
+type treeEntry struct {
+ path string // slash-separated, relative to the tree root
+ mode os.FileMode
+ size int64
+ hash string
+}
+
+func (recorder *snapshotRecorder) walk() ([]treeEntry, error) {
+ return walkTree(recorder.workspace, true)
+}
+
+// walkTree lists every regular file in root, sorted, with its content hash.
+// honourIgnores is false inside the store, where everything present belongs to
+// the snapshot by construction and a stray .gitignore must not remove files
+// from a tree that was already decided.
+//
+// A FOLDER OR FILE THE WORKSPACE WILL NOT LET US READ IS NOT PART OF THE TREE.
+// It is skipped on every walk alike, so it is in no snapshot, no change count
+// and no restore, and nothing of it is removed or written; one folder the
+// system keeps to itself (macOS answers `operation not permitted` for some
+// even to their owner) no longer ends the run before its first step. Inside
+// the store every file is ours, and an error there is still an error.
+func walkTree(root string, honourIgnores bool) ([]treeEntry, error) {
+ rules := newIgnoreRules()
+ if honourIgnores {
+ rules.load(root, "")
+ }
+ var entries []treeEntry
+ err := filepath.Walk(root, func(name string, info os.FileInfo, err error) error {
+ if err != nil {
+ return skipUnreadable(err, name != root && honourIgnores, info)
+ }
+ relative, relErr := filepath.Rel(root, name)
+ if relErr != nil {
+ return relErr
+ }
+ relative = filepath.ToSlash(relative)
+ if relative == "." {
+ return nil
+ }
+ if info.IsDir() {
+ // .git is never part of the answer, and .senior-dev is senior-dev's own
+ // bookkeeping -- the same exclusion seniorDevArtifactPathspecs makes
+ // under git.
+ if relative == ".git" || relative == ".senior-dev" ||
+ strings.HasSuffix(relative, "/.git") {
+ return filepath.SkipDir
+ }
+ if honourIgnores {
+ if rules.ignored(relative, true) {
+ return filepath.SkipDir
+ }
+ rules.load(root, relative)
+ }
+ return nil
+ }
+ // Symlinks and devices are not content, and following them would let a
+ // link out of the workspace pull in a tree that is not the answer.
+ if !info.Mode().IsRegular() {
+ return nil
+ }
+ if relative == ".senior-dev-label" {
+ return nil
+ }
+ if honourIgnores && rules.ignored(relative, false) {
+ return nil
+ }
+ hash, hashErr := hashFile(name)
+ if hashErr != nil {
+ return skipUnreadable(hashErr, honourIgnores, info)
+ }
+ entries = append(entries, treeEntry{
+ path: relative, mode: info.Mode().Perm(),
+ size: info.Size(), hash: hash,
+ })
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path })
+ return entries, nil
+}
+
+// skipUnreadable is [walkTree]'s answer to an error at one path: skip it when
+// it is a refusal to read in a walk that may skip one, and stop otherwise.
+func skipUnreadable(err error, mayskip bool, info os.FileInfo) error {
+ if !mayskip || !errors.Is(err, fs.ErrPermission) {
+ return err
+ }
+ if info != nil && info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+}
+
+// manifestID is the tree's content address: every path, mode and content hash
+// in sorted order, hashed. Mode is included so chmod +x alone is a change.
+func manifestID(entries []treeEntry) string {
+ digest := sha256.New()
+ for _, entry := range entries {
+ fmt.Fprintf(digest, "%s\x00%o\x00%s\x00", entry.path, entry.mode, entry.hash)
+ }
+ return hex.EncodeToString(digest.Sum(nil))
+}
+
+func countChangedPaths(before, after []treeEntry) int {
+ beforeByPath := map[string]string{}
+ for _, entry := range before {
+ beforeByPath[entry.path] = entry.hash
+ }
+ afterByPath := map[string]string{}
+ for _, entry := range after {
+ afterByPath[entry.path] = entry.hash
+ }
+ changed := 0
+ for path, hash := range afterByPath {
+ if previous, ok := beforeByPath[path]; !ok || previous != hash {
+ changed++
+ }
+ }
+ for path := range beforeByPath {
+ if _, ok := afterByPath[path]; !ok {
+ changed++
+ }
+ }
+ return changed
+}
+
+func hashFile(name string) (string, error) {
+ file, err := os.Open(name)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ digest := sha256.New()
+ if _, err := io.Copy(digest, file); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(digest.Sum(nil)), nil
+}
+
+func copyFile(source, destination string, mode os.FileMode) error {
+ in, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ if err := os.RemoveAll(destination); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ return err
+ }
+ return out.Close()
+}
+
+// pruneEmptyDirs removes directories a restore emptied, so a restored tree has
+// no leftover shape from the tree it replaced. Failures are ignored: an empty
+// directory is invisible to the manifest and cannot make the proof fail.
+func (recorder *snapshotRecorder) pruneEmptyDirs() {
+ var dirs []string
+ _ = filepath.Walk(recorder.workspace, func(name string, info os.FileInfo, err error) error {
+ if err != nil || !info.IsDir() {
+ return nil //nolint:nilerr // a walk error here is not worth failing a restore
+ }
+ relative, relErr := filepath.Rel(recorder.workspace, name)
+ if relErr != nil || relative == "." {
+ return nil
+ }
+ relative = filepath.ToSlash(relative)
+ if relative == ".git" || relative == ".senior-dev" {
+ return filepath.SkipDir
+ }
+ dirs = append(dirs, name)
+ return nil
+ })
+ // Deepest first, so a directory emptied by removing its children is itself
+ // removable in the same pass.
+ sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
+ for _, dir := range dirs {
+ _ = os.Remove(dir)
+ }
+}
+
+func (recorder *snapshotRecorder) ensureStore() (string, error) {
+ recorder.mu.Lock()
+ defer recorder.mu.Unlock()
+ if recorder.store != "" {
+ return recorder.store, nil
+ }
+ // Outside the workspace on purpose: a store inside it would be part of the
+ // tree it is trying to describe.
+ root := strings.TrimSpace(os.Getenv("SENIOR_DEV_SCRATCH_ROOT"))
+ if root == "" {
+ root = os.TempDir()
+ }
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return "", fmt.Errorf("create scratch root: %w", err)
+ }
+ store, err := os.MkdirTemp(root, "senior-dev-snapshots-*")
+ if err != nil {
+ return "", fmt.Errorf("create snapshot store: %w", err)
+ }
+ recorder.store = store
+ return store, nil
+}
diff --git a/internal/seniordev/app/workspace_recorder_test.go b/internal/seniordev/app/workspace_recorder_test.go
new file mode 100644
index 0000000000..0ac9c19af5
--- /dev/null
+++ b/internal/seniordev/app/workspace_recorder_test.go
@@ -0,0 +1,472 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/baked"
+)
+
+func snapshotWorkspace(t *testing.T, files map[string]string) (string, *snapshotRecorder) {
+ t.Helper()
+ workspace := t.TempDir()
+ for name, content := range files {
+ if err := writeFile(filepath.Join(workspace, name), content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ recorder := newSnapshotRecorder(workspace, func(string) {})
+ t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir())
+ return workspace, recorder
+}
+
+// The identifier is a content address: the same bytes give the same id from a
+// different directory, and any change gives a different one. Everything the
+// run does with these — the submit gate, the restore proof — rests on it.
+func TestSnapshotIdentifiesTreesByContent(t *testing.T) {
+ first, recorderA := snapshotWorkspace(t, map[string]string{
+ "main.go": "package main\n", "docs/readme.md": "hello\n",
+ })
+ _, recorderB := snapshotWorkspace(t, map[string]string{
+ "main.go": "package main\n", "docs/readme.md": "hello\n",
+ })
+ idA, err := recorderA.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ idB, err := recorderB.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if idA != idB {
+ t.Fatalf("identical trees gave different ids: %s and %s", shortSHA(idA), shortSHA(idB))
+ }
+
+ if err := writeFile(filepath.Join(first, "main.go"), "package main // edited\n"); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := recorderA.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changed == idA {
+ t.Fatal("editing a file did not change the tree id")
+ }
+}
+
+// A file's mode is part of the tree: chmod +x with no content change is a real
+// change, and a restore that dropped it would ship a broken script.
+func TestSnapshotIdentityIncludesFileMode(t *testing.T) {
+ workspace, recorder := snapshotWorkspace(t, map[string]string{"run.sh": "#!/bin/sh\n"})
+ before, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(filepath.Join(workspace, "run.sh"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ after, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatal("chmod +x did not change the tree id")
+ }
+}
+
+// The restore has to handle all three shapes of divergence at once: a file the
+// model edited, one it created, and one it deleted.
+func TestSnapshotRestoreReturnsTheExactTree(t *testing.T) {
+ workspace, recorder := snapshotWorkspace(t, map[string]string{
+ "keep.txt": "keep\n", "edit.txt": "before\n", "delete-me.txt": "doomed\n",
+ })
+ original, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := recorder.Record(original, "starting tree"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := writeFile(filepath.Join(workspace, "edit.txt"), "after\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(workspace, "delete-me.txt")); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, "nested/new.txt"), "added\n"); err != nil {
+ t.Fatal(err)
+ }
+ diverged, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diverged == original {
+ t.Fatal("the tree did not diverge")
+ }
+
+ if err := recorder.Restore(original, original); err != nil {
+ t.Fatalf("restore: %v", err)
+ }
+ content, err := os.ReadFile(filepath.Join(workspace, "edit.txt"))
+ if err != nil || string(content) != "before\n" {
+ t.Fatalf("edit.txt = %q, %v; want the recorded content", content, err)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "delete-me.txt")); err != nil {
+ t.Fatal("a deleted file was not brought back")
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "nested/new.txt")); !os.IsNotExist(err) {
+ t.Fatal("a file added after the checkpoint survived the restore")
+ }
+}
+
+// Restore proves itself by re-identifying the result. A caller that asks for a
+// tree it did not record must be told, not quietly given something else.
+func TestSnapshotRestoreRefusesAMismatch(t *testing.T) {
+ _, recorder := snapshotWorkspace(t, map[string]string{"a.txt": "one\n"})
+ id, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := recorder.Record(id, "start"); err != nil {
+ t.Fatal(err)
+ }
+ err = recorder.Restore(id, strings.Repeat("0", 64))
+ if err == nil {
+ t.Fatal("restore accepted a tree that is not the one requested")
+ }
+ if !strings.Contains(err.Error(), "restored tree") {
+ t.Fatalf("error does not name the mismatch: %v", err)
+ }
+}
+
+// Ignored paths and senior-dev's own artifacts are not the answer, so they are
+// not in the tree the run compares, freezes or restores.
+func TestSnapshotHonoursIgnoresAndSkipsArtifacts(t *testing.T) {
+ workspace, recorder := snapshotWorkspace(t, map[string]string{
+ ".gitignore": "build/\n*.log\n!keep.log\n",
+ "src/main.go": "package main\n",
+ "build/artifact.bin": "binary\n",
+ "debug.log": "noise\n",
+ "keep.log": "wanted\n",
+ ".senior-dev/spec.md": "the request\n",
+ })
+ paths, _, overBudget, err := recorder.ListPaths(context.Background(), 1<<20)
+ if err != nil || overBudget {
+ t.Fatalf("ListPaths: err=%v overBudget=%v", err, overBudget)
+ }
+ listed := strings.Join(paths, " ")
+ for _, want := range []string{".gitignore", "src/main.go", "keep.log"} {
+ if !strings.Contains(listed, want) {
+ t.Fatalf("%s missing from the tree: %v", want, paths)
+ }
+ }
+ for _, unwanted := range []string{"build/artifact.bin", "debug.log", ".senior-dev/spec.md"} {
+ if strings.Contains(listed, unwanted) {
+ t.Fatalf("%s should not be part of the answer: %v", unwanted, paths)
+ }
+ }
+ // And the ignored files are still on disk: excluded from the answer is not
+ // the same as deleted.
+ if _, err := os.Stat(filepath.Join(workspace, "debug.log")); err != nil {
+ t.Fatal("an ignored file was removed from the workspace")
+ }
+}
+
+// The submit gate asks exactly one question: has anything changed since the
+// start. It has to answer that without git.
+func TestSnapshotChangeDrivesTheSubmitGate(t *testing.T) {
+ workspace, recorder := snapshotWorkspace(t, map[string]string{"main.go": "package main\n"})
+ base, err := recorder.Base(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := recorder.Record(base, "start"); err != nil {
+ t.Fatal(err)
+ }
+
+ change, err := recorder.Change(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if change.changed {
+ t.Fatal("an untouched tree reported a change")
+ }
+
+ if err := writeFile(filepath.Join(workspace, "feature.go"), "package main\n"); err != nil {
+ t.Fatal(err)
+ }
+ change, err = recorder.Change(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !change.changed || change.files != 1 {
+ t.Fatalf("change = %+v, want one changed file", change)
+ }
+
+ // senior-dev's own bookkeeping is not an implementation: a tree whose only
+ // new content is .senior-dev/ must still read as unchanged, or every run
+ // could submit having done nothing.
+ if err := writeFile(filepath.Join(workspace, "feature.go"), "package main\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(workspace, "feature.go")); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, ".senior-dev/checklist.md"), "[x] done\n"); err != nil {
+ t.Fatal(err)
+ }
+ change, err = recorder.Change(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if change.changed {
+ t.Fatalf("senior-dev's own artifacts counted as an implementation: %+v", change)
+ }
+}
+
+// The point of the mode: a repository the run has no business writing to must
+// come out with its history untouched.
+func TestInPlaceRunLeavesGitHistoryAlone(t *testing.T) {
+ workspace := testRepoWithEntrypoints(t)
+ before := gitOutput(context.Background(), workspace, "rev-parse", "HEAD")
+ beforeLog := gitOutput(context.Background(), workspace, "log", "--oneline")
+
+ t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir())
+ host := &testHost{workspace: workspace}
+ var notes strings.Builder
+ ending := runWith(context.Background(), host, Options{
+ Goal: "Implement the thing.", High: "provider/high", InPlace: true,
+ }, ¬es, &coderOnlyBackend{})
+ if ending.Status == "crashed" {
+ t.Fatalf("in-place run failed: %s\n%s", ending.Message, notes.String())
+ }
+
+ after := gitOutput(context.Background(), workspace, "rev-parse", "HEAD")
+ if after != before {
+ t.Fatalf("HEAD moved: %s -> %s", shortSHA(before), shortSHA(after))
+ }
+ if now := gitOutput(context.Background(), workspace, "log", "--oneline"); now != beforeLog {
+ t.Fatalf("the run wrote history:\nbefore:\n%s\nafter:\n%s", beforeLog, now)
+ }
+ if !strings.Contains(notes.String(), `"workspace_recorder":"snapshot"`) {
+ t.Fatal("the run contract does not record the snapshot recorder")
+ }
+}
+
+// And the mode's other half: no repository at all.
+func TestInPlaceRunNeedsNoRepository(t *testing.T) {
+ workspace := t.TempDir()
+ for name, content := range map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ } {
+ if err := writeFile(filepath.Join(workspace, name), content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) {
+ t.Fatal("the fixture is a repository; this test needs one that is not")
+ }
+
+ t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir())
+ var notes strings.Builder
+ ending := runWith(context.Background(), &testHost{workspace: workspace}, Options{
+ Goal: "Implement the thing.", High: "provider/high", InPlace: true,
+ }, ¬es, &coderOnlyBackend{})
+ if ending.Status == "crashed" {
+ t.Fatalf("run without a repository failed: %s\n%s", ending.Message, notes.String())
+ }
+ if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) {
+ t.Fatal("the run created a repository in a workspace that had none")
+ }
+}
+
+// Without --in-place, git is used only if it is there. A plain folder runs
+// on the snapshot recorder and ends like any run, never with "not a git
+// repository", and no repository is made for it.
+func TestADefaultRunInAPlainFolderUsesTheSnapshotRecorder(t *testing.T) {
+ workspace := t.TempDir()
+ for name, content := range map[string]string{
+ "README.md": "base\n",
+ "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n",
+ } {
+ if err := writeFile(filepath.Join(workspace, name), content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir())
+ var notes strings.Builder
+ ending := runWith(context.Background(), &testHost{workspace: workspace}, Options{
+ Goal: "Implement the thing.", High: "provider/high",
+ }, ¬es, &coderOnlyBackend{})
+ if ending.Status == "crashed" {
+ t.Fatalf("a plain folder crashed the default run: %s\n%s", ending.Message, notes.String())
+ }
+ if !strings.Contains(notes.String(), `"workspace_recorder":"snapshot"`) {
+ t.Fatal("the run contract does not record the snapshot recorder")
+ }
+ if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) {
+ t.Fatal("the run created a repository in a workspace that had none")
+ }
+}
+
+// The recorder follows what git can actually give: a work tree with a commit
+// is git's, and everything short of that — no repository, one with no commit
+// yet, a .git folder with no HEAD — is the snapshot recorder's. --in-place
+// still forces the snapshot recorder over a real repository.
+func TestTheRecorderIsGitOnlyWhereThereIsGitHistory(t *testing.T) {
+ ctx := context.Background()
+ git := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{
+ "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false",
+ }, args...)...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ plain := t.TempDir()
+ empty := t.TempDir()
+ git(empty, "init", "-q")
+ broken := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(broken, ".git", "objects"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ committed := t.TempDir()
+ git(committed, "init", "-q")
+ if err := writeFile(filepath.Join(committed, "a.txt"), "a\n"); err != nil {
+ t.Fatal(err)
+ }
+ git(committed, "add", "a.txt")
+ git(committed, "commit", "-q", "-m", "base")
+ nested := filepath.Join(committed, "sub")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tc := range []struct {
+ name, dir string
+ inPlace bool
+ want string
+ }{
+ {"plain folder", plain, false, "snapshot"},
+ {"repository with no commit", empty, false, "snapshot"},
+ {"a .git folder with no HEAD", broken, false, "snapshot"},
+ {"repository with a commit", committed, false, "git"},
+ {"a folder inside one", nested, false, "git"},
+ {"--in-place over a repository", committed, true, "snapshot"},
+ } {
+ got := newWorkspaceRecorder(cliArgs{InPlace: tc.inPlace}, tc.dir, func(string) {})
+ if got.Kind() != tc.want {
+ t.Errorf("%s: recorder %q, want %q", tc.name, got.Kind(), tc.want)
+ }
+ if err := got.Prepare(ctx); err != nil {
+ t.Errorf("%s: %s recorder did not prepare: %v", tc.name, got.Kind(), err)
+ }
+ }
+}
+
+// Every in-place rewrite must fire against the real prompt text. A rewrite
+// that silently matched nothing would leave the model with git-shaped
+// instructions it cannot follow, which is invisible at runtime.
+func TestInPlacePromptRewritesAllMatch(t *testing.T) {
+ coder, ok := baked.GetBakedAgent("coder")
+ if !ok {
+ t.Fatal("the coder agent is not available")
+ }
+ solo := buildSoloPrompt("Do the thing.", "", ".senior-dev/checklist.md")
+ recorder := newSnapshotRecorder(t.TempDir(), func(string) {})
+
+ adaptedCoder, err := adaptCoderPrompt(recorder, coder)
+ if err != nil {
+ t.Fatalf("a coder rewrite no longer matches: %v", err)
+ }
+ adaptedSolo, err := adaptSoloPrompt(recorder, solo)
+ if err != nil {
+ t.Fatalf("a run-instruction rewrite no longer matches: %v", err)
+ }
+ for _, leftover := range []string{"starting commit", "git-ignored", "is a git repository"} {
+ if strings.Contains(adaptedCoder, leftover) {
+ t.Fatalf("coder prompt still says %q", leftover)
+ }
+ if strings.Contains(adaptedSolo, leftover) {
+ t.Fatalf("solo prompt still says %q", leftover)
+ }
+ }
+ if !strings.Contains(adaptedSolo, "does not use git") {
+ t.Fatal("the solo prompt does not tell the model git is unavailable")
+ }
+}
+
+// The git path's prompt bytes are the deliverable of choosing substitution
+// over rewording: an unchanged prompt hash keeps earlier runs comparable.
+func TestGitRecorderLeavesPromptsByteIdentical(t *testing.T) {
+ coder, _ := baked.GetBakedAgent("coder")
+ solo := buildSoloPrompt("Do the thing.", "", ".senior-dev/checklist.md")
+ recorder := newGitRecorder(t.TempDir(), func(string) {})
+
+ adaptedCoder, err := adaptCoderPrompt(recorder, coder)
+ if err != nil {
+ t.Fatal(err)
+ }
+ adaptedSolo, err := adaptSoloPrompt(recorder, solo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if adaptedCoder != coder {
+ t.Fatal("the git path's system prompt changed")
+ }
+ if adaptedSolo != solo {
+ t.Fatal("the git path's run instruction changed")
+ }
+}
+
+// A folder or file the workspace will not let the run read is not part of the
+// tree: the run is not ended by it, no snapshot holds it, and a restore neither
+// removes nor writes it. macOS answers `operation not permitted` for some
+// folders even to their owner, and one of them ended a run at its first step.
+func TestSnapshotSkipsWhatItMayNotRead(t *testing.T) {
+ workspace, recorder := snapshotWorkspace(t, map[string]string{
+ "main.go": "package main\n", "locked/inside.txt": "private\n", "sealed.txt": "private\n",
+ })
+ locked, sealed := filepath.Join(workspace, "locked"), filepath.Join(workspace, "sealed.txt")
+ for _, name := range []string{locked, sealed} {
+ if err := os.Chmod(name, 0); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Cleanup(func() { _ = os.Chmod(locked, 0o755); _ = os.Chmod(sealed, 0o644) })
+ if _, err := os.ReadDir(locked); err == nil {
+ t.Skip("this user reads a folder with no permissions (root?)")
+ }
+ original, err := recorder.Snapshot()
+ if err != nil {
+ t.Fatalf("snapshot of a workspace holding an unreadable folder: %v", err)
+ }
+ paths, _, _, err := recorder.ListPaths(context.Background(), 1<<20)
+ if err != nil || strings.Join(paths, ",") != "main.go" {
+ t.Fatalf("paths = %q, %v; want only the readable file", paths, err)
+ }
+ if _, err := recorder.Record(original, "start"); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFile(filepath.Join(workspace, "main.go"), "package main // edited\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := recorder.Restore(original, original); err != nil {
+ t.Fatalf("restore: %v", err)
+ }
+ for _, name := range []string{locked, sealed} {
+ if _, err := os.Lstat(name); err != nil {
+ t.Fatalf("the restore touched %s: %v", name, err)
+ }
+ }
+}
diff --git a/internal/seniordev/app/worktree_fingerprint.go b/internal/seniordev/app/worktree_fingerprint.go
new file mode 100644
index 0000000000..574c2fba22
--- /dev/null
+++ b/internal/seniordev/app/worktree_fingerprint.go
@@ -0,0 +1,225 @@
+//go:build !windows
+
+package app
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ worktreeFingerprintMaxFiles = 4096
+ worktreeFingerprintMaxBytes = 8 * 1024 * 1024
+ worktreeFingerprintTimeout = 2 * time.Second
+)
+
+type worktreeFileFingerprint struct {
+ Mode os.FileMode
+ Size int64
+ ModTimeNano int64
+ Missing bool
+ ContentHash string
+}
+
+type fingerprintStatus uint8
+
+const (
+ fingerprintOK fingerprintStatus = iota
+ fingerprintFailed
+ fingerprintOverBudget
+)
+
+type worktreeFingerprinter struct {
+ runner *pipeline
+ parent context.Context
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func newWorktreeFingerprinter(
+ runner *pipeline, parent context.Context,
+) *worktreeFingerprinter {
+ ctx, cancel := context.WithTimeout(parent, worktreeFingerprintTimeout)
+ return &worktreeFingerprinter{
+ runner: runner, parent: parent, ctx: ctx, cancel: cancel,
+ }
+}
+
+func (fingerprinter *worktreeFingerprinter) fingerprint() (string, bool) {
+ defer fingerprinter.cancel()
+ paths, remaining, status := fingerprinter.listPaths()
+ if status != fingerprintOK {
+ return fingerprinter.result(status)
+ }
+ digest := sha256.New()
+ nextFiles := make(map[string]worktreeFileFingerprint, len(paths))
+ for _, relative := range paths {
+ if status = fingerprinter.contextStatus(); status != fingerprintOK {
+ return fingerprinter.result(status)
+ }
+ state, consumed, status := fingerprinter.fingerprintPath(relative, remaining)
+ if status != fingerprintOK {
+ return fingerprinter.result(status)
+ }
+ remaining -= consumed
+ nextFiles[relative] = state
+ writeFingerprintState(digest, relative, state)
+ }
+ fingerprinter.runner.fingerprintFiles = nextFiles
+ return fmt.Sprintf("%x", digest.Sum(nil)), true
+}
+
+func (fingerprinter *worktreeFingerprinter) result(
+ status fingerprintStatus,
+) (string, bool) {
+ if status == fingerprintOverBudget {
+ return fingerprinter.runner.overBudgetFingerprint(), true
+ }
+ return "", false
+}
+
+func (fingerprinter *worktreeFingerprinter) contextStatus() fingerprintStatus {
+ if fingerprinter.ctx.Err() == nil {
+ return fingerprintOK
+ }
+ if fingerprinter.parent.Err() == nil {
+ return fingerprintOverBudget
+ }
+ return fingerprintFailed
+}
+
+func (fingerprinter *worktreeFingerprinter) listPaths() (
+ []string, int64, fingerprintStatus,
+) {
+ paths, consumed, overBudget, err := fingerprinter.runner.recorder.ListPaths(
+ fingerprinter.ctx, worktreeFingerprintMaxBytes,
+ )
+ if overBudget {
+ return nil, 0, fingerprintOverBudget
+ }
+ if status := fingerprinter.contextStatus(); status != fingerprintOK {
+ return nil, 0, status
+ }
+ if err != nil {
+ return nil, 0, fingerprintFailed
+ }
+ if len(paths) > worktreeFingerprintMaxFiles {
+ return nil, 0, fingerprintOverBudget
+ }
+ return paths, int64(worktreeFingerprintMaxBytes - consumed), fingerprintOK
+}
+
+func (fingerprinter *worktreeFingerprinter) fingerprintPath(
+ relative string, remaining int64,
+) (worktreeFileFingerprint, int64, fingerprintStatus) {
+ path := filepath.Join(
+ fingerprinter.runner.workspace, filepath.FromSlash(relative),
+ )
+ info, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ return worktreeFileFingerprint{Missing: true}, 0, fingerprintOK
+ }
+ if err != nil {
+ return worktreeFileFingerprint{}, 0, fingerprintFailed
+ }
+ state := worktreeFileFingerprint{
+ Mode: info.Mode(), Size: info.Size(), ModTimeNano: info.ModTime().UnixNano(),
+ }
+ cached, ok := fingerprinter.runner.fingerprintFiles[relative]
+ metadataUnchanged := ok && !cached.Missing && cached.Mode == state.Mode &&
+ cached.Size == state.Size && cached.ModTimeNano == state.ModTimeNano
+ if metadataUnchanged {
+ state.ContentHash = cached.ContentHash
+ return state, 0, fingerprintOK
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fingerprintSymlink(path, state, remaining)
+ }
+ if info.Mode().IsRegular() {
+ return fingerprintRegularFile(fingerprinter.ctx, path, state, remaining)
+ }
+ return state, 0, fingerprintOK
+}
+
+func fingerprintSymlink(
+ path string, state worktreeFileFingerprint, remaining int64,
+) (worktreeFileFingerprint, int64, fingerprintStatus) {
+ target, err := os.Readlink(path)
+ if err != nil {
+ return worktreeFileFingerprint{}, 0, fingerprintFailed
+ }
+ consumed := int64(len(target))
+ if consumed > remaining {
+ return worktreeFileFingerprint{}, 0, fingerprintOverBudget
+ }
+ sum := sha256.Sum256([]byte(target))
+ state.ContentHash = fmt.Sprintf("%x", sum[:])
+ return state, consumed, fingerprintOK
+}
+
+func fingerprintRegularFile(
+ ctx context.Context, path string, state worktreeFileFingerprint, remaining int64,
+) (worktreeFileFingerprint, int64, fingerprintStatus) {
+ contentHash, consumed, ok := boundedFileHash(ctx, path, remaining)
+ if !ok {
+ return worktreeFileFingerprint{}, 0, fingerprintOverBudget
+ }
+ state.ContentHash = contentHash
+ return state, consumed, fingerprintOK
+}
+
+func writeFingerprintState(
+ digest io.Writer, relative string, state worktreeFileFingerprint,
+) {
+ _, _ = digest.Write([]byte(relative + "\x00"))
+ if state.Missing {
+ _, _ = digest.Write([]byte("missing\x00" + state.ContentHash + "\x00"))
+ return
+ }
+ _, _ = digest.Write([]byte(fmt.Sprintf(
+ "%s\x00%d\x00%d\x00%s\x00",
+ state.Mode, state.Size, state.ModTimeNano, state.ContentHash,
+ )))
+}
+
+func boundedFileHash(
+ ctx context.Context, path string, remaining int64,
+) (string, int64, bool) {
+ if remaining < 0 {
+ return "", 0, false
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return "", 0, false
+ }
+ defer file.Close()
+ digest := sha256.New()
+ buffer := make([]byte, 64*1024)
+ limited := io.LimitReader(file, remaining+1)
+ consumed := int64(0)
+ for {
+ if ctx.Err() != nil {
+ return "", consumed, false
+ }
+ read, readErr := limited.Read(buffer)
+ if read > 0 {
+ consumed += int64(read)
+ _, _ = digest.Write(buffer[:read])
+ if consumed > remaining {
+ return "", consumed, false
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return "", consumed, false
+ }
+ }
+ return fmt.Sprintf("%x", digest.Sum(nil)), consumed, true
+}
diff --git a/internal/seniordev/baked/agents/coder.md b/internal/seniordev/baked/agents/coder.md
new file mode 100644
index 0000000000..83ee5f4e16
--- /dev/null
+++ b/internal/seniordev/baked/agents/coder.md
@@ -0,0 +1,71 @@
+---
+mode: subagent
+description: >-
+ End-to-end implementation generalist for one bounded task: orient, implement,
+ verify, and return evidence in a single context.
+model: inherit
+temperature: 0.2
+permission:
+ "*": allow
+ doom_loop: ask
+tools:
+ read: true
+ grep: true
+ glob: true
+ bash: true
+ edit: true
+ write: true
+ apply_patch: true
+---
+
+
+
+You are the only agent in this run: one request, one context, from exploration
+through implementation and verification. There is no planner, no reviewer, no
+subagent and no tool to delegate with.
+
+The run is unattended. The `question` tool is available, but nobody is there
+to answer it: every question it sends comes back rejected.
+
+The working tree you leave behind is the answer.
+
+
+
+
+
+**The run ends when you call the `submit` tool, and by nothing else.** No status
+tag, verdict, report or summary finishes it, however well evidenced.
+
+`submit` takes a `reason`, the `evidence` you verified with, and
+`checklist_satisfied`. It refuses, naming the cause, when the tree is unchanged
+from the starting commit, when `.senior-dev/checklist.md` does not exist, when
+`reason` or `evidence` is empty, or when this run already submitted. A refusal
+does not end the run: fix what it names and call `submit` again.
+
+An accepted `submit` freezes the tree as your answer at that instant. Anything
+changed afterwards is reverted to the frozen tree before it ships.
+
+
+
+
+
+`.senior-dev/spec.md` holds the request verbatim and is the specification. It is
+re-pinned verbatim whenever the context is compacted, so it is readable from the
+file at any point in the run.
+
+`.senior-dev/checklist.md` is the list of what the request requires: one item per
+line starting `[ ] `, ticked to `[x]`. Its item and tick counts are recorded
+when you submit.
+
+`.senior-dev/pinned.txt` holds the build or test command you are using, on one line.
+senior-dev reads its first line and quotes it back to you if this run needs a
+continuation.
+
+`.senior-dev/` and git-ignored paths are excluded from the answer. Everything else
+in the working tree is part of what you submit.
+
+After you submit, senior-dev discovers and runs this project's own build and test
+entrypoints itself, independently of anything you report. Do not edit this
+project's test, CI or coverage configuration to make them pass.
+
+
diff --git a/internal/seniordev/baked/registry.go b/internal/seniordev/baked/registry.go
new file mode 100644
index 0000000000..209bf78ead
--- /dev/null
+++ b/internal/seniordev/baked/registry.go
@@ -0,0 +1,105 @@
+//go:build !windows
+
+// Package baked embeds the agent document the run executes. The roster is a
+// single agent, coder: the pipeline reads its prompt body, its frontmatter
+// metadata (model, steps, tier) and its permission rules.
+package baked
+
+import (
+ "embed"
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+var agentNames = []string{"coder"}
+
+//go:embed agents/*.md
+var agentFiles embed.FS
+
+type agentDocument struct {
+ raw string
+ prompt string
+ metadata map[string]any
+}
+
+var agentDocuments = loadAgentDocuments()
+
+func loadAgentDocuments() map[string]agentDocument {
+ out := make(map[string]agentDocument, len(agentNames))
+ for _, name := range agentNames {
+ data, err := agentFiles.ReadFile("agents/" + name + ".md")
+ if err != nil {
+ panic("baked agent asset missing: " + name)
+ }
+ raw := string(data)
+ prompt, frontmatter, err := parseAgentMarkdown(raw)
+ if err != nil {
+ panic(fmt.Sprintf("baked agent %q frontmatter: %v", name, err))
+ }
+ metadata := map[string]any{}
+ if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil {
+ panic(fmt.Sprintf("baked agent %q frontmatter: %v", name, err))
+ }
+ out[name] = agentDocument{raw: raw, prompt: prompt, metadata: metadata}
+ }
+ return out
+}
+
+func parseAgentMarkdown(markdown string) (string, string, error) {
+ normalized := strings.ReplaceAll(markdown, "\r\n", "\n")
+ if !strings.HasPrefix(normalized, "---\n") {
+ return strings.TrimSpace(normalized), "", nil
+ }
+ rest := normalized[len("---\n"):]
+ end := strings.Index(rest, "\n---")
+ if end < 0 {
+ return "", "", fmt.Errorf("unterminated YAML frontmatter")
+ }
+ after := rest[end+len("\n---"):]
+ if after != "" && !strings.HasPrefix(after, "\n") {
+ return "", "", fmt.Errorf("closing YAML delimiter is not on its own line")
+ }
+ return strings.TrimSpace(strings.TrimPrefix(after, "\n")), rest[:end], nil
+}
+
+// PromptContent strips YAML frontmatter and trims the model-visible body.
+func PromptContent(markdown string) string {
+ prompt, _, err := parseAgentMarkdown(markdown)
+ if err != nil {
+ return ""
+ }
+ return prompt
+}
+
+// GetBakedAgent returns only the model-visible Markdown body for an agent.
+func GetBakedAgent(name string) (string, bool) {
+ document, ok := agentDocuments[name]
+ return document.prompt, ok
+}
+
+// GetBakedAgentMarkdown returns the source document for frontmatter consumers.
+func GetBakedAgentMarkdown(name string) (string, bool) {
+ document, ok := agentDocuments[name]
+ return document.raw, ok
+}
+
+// GetBakedAgentMetadata returns the parsed YAML fields used to configure an
+// agent without exposing them to the model.
+func GetBakedAgentMetadata(name string) (map[string]any, bool) {
+ document, ok := agentDocuments[name]
+ if !ok {
+ return nil, false
+ }
+ metadata := make(map[string]any, len(document.metadata))
+ for key, value := range document.metadata {
+ metadata[key] = value
+ }
+ return metadata, true
+}
+
+// ListBakedAgents returns the baked agent names in registry order.
+func ListBakedAgents() []string {
+ return append([]string(nil), agentNames...)
+}
diff --git a/internal/seniordev/baked/tier.go b/internal/seniordev/baked/tier.go
new file mode 100644
index 0000000000..5a6afe1032
--- /dev/null
+++ b/internal/seniordev/baked/tier.go
@@ -0,0 +1,70 @@
+//go:build !windows
+
+package baked
+
+import "strings"
+
+// Tier is a model routing pool. The router keeps one pool per tier and
+// resolves any tier whose pool is empty to the high pool, so a run given
+// nothing but `--high` routes every tier on that one pool.
+type Tier string
+
+const (
+ TierHigh Tier = "high"
+ TierLow Tier = "low"
+ TierFrontier Tier = "frontier"
+)
+
+// tierMap is the agent-to-tier mapping. This table and the optional `tier:`
+// frontmatter key that overrides it are the only things that decide which
+// pool a call routes on.
+//
+// - coder: the implementation turns, on the high pool.
+// - compaction: the transcript summary call, on the low pool. It is an
+// auxiliary call that recurs through a long run, so it is the one place
+// a cheaper pool is worth configuring.
+var tierMap = map[string]Tier{
+ "coder": TierHigh,
+ "compaction": TierLow,
+}
+
+// TierFor returns the named agent's routing tier. A baked agent may override
+// the table with a `tier:` frontmatter key; an absent or unrecognised value,
+// and any name the table does not list, routes on the high pool.
+func TierFor(name string) Tier {
+ metadata, _ := GetBakedAgentMetadata(name)
+ return tierFrom(metadata, name)
+}
+
+// tierFrom answers for an agent whose frontmatter metadata is already in
+// hand. A nil map means the name has no baked document, which is how the
+// compaction summary reaches the table.
+func tierFrom(metadata map[string]any, name string) Tier {
+ if tier, ok := parseTier(metadata["tier"]); ok {
+ return tier
+ }
+ if tier, ok := tierMap[name]; ok {
+ return tier
+ }
+ return TierHigh
+}
+
+// parseTier reads a frontmatter `tier:` value. It reports false for anything
+// that is not one of the three tier names, leaving the table's answer in
+// place.
+func parseTier(value any) (Tier, bool) {
+ text, ok := value.(string)
+ if !ok {
+ return "", false
+ }
+ switch Tier(strings.ToLower(strings.TrimSpace(text))) {
+ case TierHigh:
+ return TierHigh, true
+ case TierLow:
+ return TierLow, true
+ case TierFrontier:
+ return TierFrontier, true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/seniordev/baked/tier_test.go b/internal/seniordev/baked/tier_test.go
new file mode 100644
index 0000000000..8355b7858e
--- /dev/null
+++ b/internal/seniordev/baked/tier_test.go
@@ -0,0 +1,79 @@
+//go:build !windows
+
+package baked
+
+import (
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestTierForMapsEachAgentToItsPool(t *testing.T) {
+ for _, test := range []struct {
+ agent string
+ want Tier
+ }{
+ {"coder", TierHigh},
+ {"compaction", TierLow},
+ {"", TierHigh},
+ {"some-agent-that-does-not-exist", TierHigh},
+ } {
+ if got := TierFor(test.agent); got != test.want {
+ t.Errorf("TierFor(%q) = %q, want %q", test.agent, got, test.want)
+ }
+ }
+}
+
+func TestShippedCoderDocumentLeavesTheTierToTheTable(t *testing.T) {
+ // The override exists for an operator; the shipped document must not use
+ // it, or the table stops describing what the binary does.
+ metadata, ok := GetBakedAgentMetadata("coder")
+ if !ok {
+ t.Fatal("the coder document is missing")
+ }
+ if value, present := metadata["tier"]; present {
+ t.Fatalf("coder.md sets tier: %v", value)
+ }
+}
+
+func TestFrontmatterTierOverridesTheTable(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ value string
+ want Tier
+ }{
+ {"frontier", "tier: frontier\n", TierFrontier},
+ {"low", "tier: low\n", TierLow},
+ {"case and space are forgiven", "tier: \" Frontier \"\n", TierFrontier},
+ {"an unknown value keeps the table's answer", "tier: platinum\n", TierHigh},
+ {"a non-string keeps the table's answer", "tier: 3\n", TierHigh},
+ {"no key at all keeps the table's answer", "", TierHigh},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ // Parsed the same way the embedded documents are, so the test
+ // covers the frontmatter path and not just the lookup.
+ _, frontmatter, err := parseAgentMarkdown(
+ "---\nmodel: inherit\n" + test.value + "---\n\nbody\n",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ metadata := map[string]any{}
+ if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil {
+ t.Fatal(err)
+ }
+ if got := tierFrom(metadata, "coder"); got != test.want {
+ t.Fatalf("tier = %q, want %q", got, test.want)
+ }
+ })
+ }
+}
+
+func TestFrontmatterTierOverridesTheCompactionDefaultToo(t *testing.T) {
+ if got := tierFrom(map[string]any{"tier": "high"}, "compaction"); got != TierHigh {
+ t.Fatalf("tier = %q, want %q", got, TierHigh)
+ }
+ if got := tierFrom(nil, "compaction"); got != TierLow {
+ t.Fatalf("tier = %q, want %q", got, TierLow)
+ }
+}
diff --git a/internal/seniordev/bus/bus.go b/internal/seniordev/bus/bus.go
new file mode 100644
index 0000000000..0135488032
--- /dev/null
+++ b/internal/seniordev/bus/bus.go
@@ -0,0 +1,296 @@
+//go:build !windows
+
+// Package bus is the in-process event bus. Subscriber snapshots are invoked
+// synchronously in registration order. All mutable state is protected for
+// concurrent publishers/subscribers.
+package bus
+
+import (
+ "sync"
+
+ idpkg "github.com/Agent-Field/codeaf/internal/seniordev/id"
+)
+
+// Payload is the wire event delivered to subscribers.
+type Payload struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Properties any `json:"properties"`
+}
+
+// Context is the instance metadata a bus is created for.
+type Context struct {
+ Directory string
+ Project string
+ Workspace string
+}
+
+// PublishOptions lets a publisher pin the payload ID.
+type PublishOptions struct {
+ ID string
+}
+
+type subscriber struct {
+ id uint64
+ callback func(Payload)
+}
+
+// Bus is an instance-scoped pub/sub bus.
+type Bus struct {
+ mu sync.RWMutex
+ nextID uint64
+ typed map[string][]subscriber
+ wildcard []subscriber
+ context Context
+ createID func() string
+ disposed bool
+ streams map[*Subscription]struct{}
+}
+
+// BusOption configures New.
+type BusOption func(*Bus)
+
+// WithIDGenerator pins payload IDs.
+func WithIDGenerator(createID func() string) BusOption {
+ return func(bus *Bus) { bus.createID = createID }
+}
+
+// New constructs an instance bus.
+func New(context Context, options ...BusOption) *Bus {
+ bus := &Bus{
+ typed: make(map[string][]subscriber),
+ context: context,
+ createID: CreateID,
+ streams: make(map[*Subscription]struct{}),
+ }
+ for _, option := range options {
+ option(bus)
+ }
+ return bus
+}
+
+// CreateID creates an ascending evt identifier.
+func CreateID() string {
+ value, err := idpkg.Create("evt", idpkg.AscendingDirection)
+ if err != nil {
+ panic(err)
+ }
+ return value
+}
+
+// Publish delivers to typed subscribers, then to wildcard subscribers, in
+// that order.
+func (b *Bus) Publish(def Definition, properties any, options ...PublishOptions) {
+ id := ""
+ if len(options) > 0 {
+ id = options[0].ID
+ }
+ if id == "" {
+ id = b.createID()
+ }
+ payload := Payload{ID: id, Type: def.Type, Properties: properties}
+
+ b.mu.RLock()
+ if b.disposed {
+ b.mu.RUnlock()
+ return
+ }
+ typed := append([]subscriber(nil), b.typed[def.Type]...)
+ wildcard := append([]subscriber(nil), b.wildcard...)
+ b.mu.RUnlock()
+
+ deliver(typed, payload)
+ deliver(wildcard, payload)
+}
+
+// SubscribeCallback subscribes to one event definition.
+func (b *Bus) SubscribeCallback(def Definition, callback func(Payload)) func() {
+ return b.subscribe(def.Type, callback, false)
+}
+
+// SubscribeAllCallback subscribes to every event.
+func (b *Bus) SubscribeAllCallback(callback func(Payload)) func() {
+ return b.subscribe("*", callback, true)
+}
+
+func (b *Bus) subscribe(eventType string, callback func(Payload), all bool) func() {
+ b.mu.Lock()
+ if b.disposed {
+ b.mu.Unlock()
+ return func() {}
+ }
+ b.nextID++
+ id := b.nextID
+ item := subscriber{id: id, callback: callback}
+ if all {
+ b.wildcard = append(b.wildcard, item)
+ } else {
+ b.typed[eventType] = append(b.typed[eventType], item)
+ }
+ b.mu.Unlock()
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if all {
+ b.wildcard = removeSubscriber(b.wildcard, id)
+ return
+ }
+ b.typed[eventType] = removeSubscriber(b.typed[eventType], id)
+ })
+ }
+}
+
+func removeSubscriber(subscribers []subscriber, id uint64) []subscriber {
+ for i, item := range subscribers {
+ if item.id == id {
+ return append(subscribers[:i], subscribers[i+1:]...)
+ }
+ }
+ return subscribers
+}
+
+func deliver(subscribers []subscriber, payload Payload) {
+ for _, item := range subscribers {
+ func() {
+ defer func() { _ = recover() }()
+ item.callback(payload)
+ }()
+ }
+}
+
+// Dispose publishes InstanceDisposed to wildcard subscribers only, then closes
+// streams and makes later publishes/subscriptions inert.
+func (b *Bus) Dispose() {
+ b.mu.Lock()
+ if b.disposed {
+ b.mu.Unlock()
+ return
+ }
+ b.disposed = true
+ wildcard := append([]subscriber(nil), b.wildcard...)
+ streams := make([]*Subscription, 0, len(b.streams))
+ for stream := range b.streams {
+ streams = append(streams, stream)
+ }
+ b.typed = make(map[string][]subscriber)
+ b.wildcard = nil
+ b.streams = make(map[*Subscription]struct{})
+ directory := b.context.Directory
+ b.mu.Unlock()
+
+ deliver(wildcard, Payload{
+ ID: b.createID(),
+ Type: InstanceDisposed.Type,
+ Properties: map[string]any{"directory": directory},
+ })
+ for _, stream := range streams {
+ stream.close()
+ }
+}
+
+// Subscription is an unbounded ordered stream subscription.
+type Subscription struct {
+ C <-chan Payload
+
+ out chan Payload
+ mu sync.Mutex
+ cond *sync.Cond
+ queue []Payload
+ closed bool
+ closeOnce sync.Once
+ unsub func()
+}
+
+// Subscribe returns a typed stream. Call Close when finished.
+func (b *Bus) Subscribe(def Definition) *Subscription {
+ return b.newStream(func(push func(Payload)) func() {
+ return b.SubscribeCallback(def, push)
+ })
+}
+
+// SubscribeAll returns a wildcard stream.
+func (b *Bus) SubscribeAll() *Subscription {
+ return b.newStream(func(push func(Payload)) func() {
+ return b.SubscribeAllCallback(push)
+ })
+}
+
+func (b *Bus) newStream(register func(func(Payload)) func()) *Subscription {
+ out := make(chan Payload)
+ subscription := &Subscription{out: out}
+ subscription.C = out
+ subscription.cond = sync.NewCond(&subscription.mu)
+ subscription.unsub = register(subscription.push)
+ b.mu.Lock()
+ if b.disposed {
+ b.mu.Unlock()
+ subscription.close()
+ return subscription
+ }
+ b.streams[subscription] = struct{}{}
+ b.mu.Unlock()
+ go subscription.run()
+ return subscription
+}
+
+func (s *Subscription) push(payload Payload) {
+ s.mu.Lock()
+ if !s.closed {
+ s.queue = append(s.queue, payload)
+ s.cond.Signal()
+ }
+ s.mu.Unlock()
+}
+
+func (s *Subscription) run() {
+ defer close(s.out)
+ for {
+ s.mu.Lock()
+ for len(s.queue) == 0 && !s.closed {
+ s.cond.Wait()
+ }
+ if len(s.queue) == 0 && s.closed {
+ s.mu.Unlock()
+ return
+ }
+ payload := s.queue[0]
+ s.queue = s.queue[1:]
+ s.mu.Unlock()
+ s.out <- payload
+ }
+}
+
+// Close unsubscribes and closes C after already queued events are delivered.
+func (s *Subscription) Close() { s.close() }
+
+func (s *Subscription) close() {
+ s.closeOnce.Do(func() {
+ if s.unsub != nil {
+ s.unsub()
+ }
+ s.mu.Lock()
+ s.closed = true
+ s.cond.Broadcast()
+ s.mu.Unlock()
+ })
+}
+
+// Default is the package-level runtime used by the convenience functions.
+var Default = New(Context{})
+
+// Publish emits on Default.
+func Publish(def Definition, properties any, options ...PublishOptions) {
+ Default.Publish(def, properties, options...)
+}
+
+// SubscribeCallback subscribes on Default.
+func SubscribeCallback(def Definition, callback func(Payload)) func() {
+ return Default.SubscribeCallback(def, callback)
+}
+
+// SubscribeAllCallback subscribes on Default.
+func SubscribeAllCallback(callback func(Payload)) func() {
+ return Default.SubscribeAllCallback(callback)
+}
diff --git a/internal/seniordev/bus/bus_test.go b/internal/seniordev/bus/bus_test.go
new file mode 100644
index 0000000000..4c353fc003
--- /dev/null
+++ b/internal/seniordev/bus/bus_test.go
@@ -0,0 +1,136 @@
+//go:build !windows
+
+package bus
+
+import (
+ "fmt"
+ "reflect"
+ "sync"
+ "testing"
+ "time"
+)
+
+func sequenceIDs() func() string {
+ var mu sync.Mutex
+ next := 0
+ return func() string {
+ mu.Lock()
+ defer mu.Unlock()
+ next++
+ return fmt.Sprintf("evt_%d", next)
+ }
+}
+
+func TestPublishOrderUnsubscribeAndPanicIsolation(t *testing.T) {
+ b := New(
+ Context{Directory: "/repo", Project: "p", Workspace: "w"},
+ WithIDGenerator(sequenceIDs()),
+ )
+ def := Define("test.order", nil)
+ var got []string
+ b.SubscribeCallback(def, func(Payload) { got = append(got, "typed-1") })
+ b.SubscribeCallback(def, func(Payload) { panic("subscriber failed") })
+ unsubscribe := b.SubscribeCallback(def, func(Payload) { got = append(got, "typed-3") })
+ b.SubscribeAllCallback(func(Payload) { got = append(got, "all-1") })
+
+ b.Publish(def, map[string]any{"x": float64(1)}, PublishOptions{ID: "fixed"})
+ want := []string{"typed-1", "typed-3", "all-1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("delivery order: %v, want %v", got, want)
+ }
+ unsubscribe()
+ unsubscribe()
+ got = nil
+ b.Publish(def, nil)
+ want = []string{"typed-1", "all-1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("after unsubscribe: %v, want %v", got, want)
+ }
+}
+
+func TestSnapshotSemanticsForMutationDuringPublish(t *testing.T) {
+ b := New(Context{}, WithIDGenerator(sequenceIDs()))
+ def := Define("test.snapshot", nil)
+ var got []string
+ var unsubscribeSecond func()
+ b.SubscribeCallback(def, func(Payload) {
+ got = append(got, "first")
+ unsubscribeSecond()
+ b.SubscribeCallback(def, func(Payload) { got = append(got, "late") })
+ })
+ unsubscribeSecond = b.SubscribeCallback(def, func(Payload) { got = append(got, "second") })
+ b.Publish(def, nil)
+ if want := []string{"first", "second"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("first publish: %v", got)
+ }
+ got = nil
+ b.Publish(def, nil)
+ if want := []string{"first", "late"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("second publish: %v", got)
+ }
+}
+
+func TestDisposeOnlyNotifiesWildcardAndClosesStreams(t *testing.T) {
+ b := New(Context{Directory: "/d"}, WithIDGenerator(sequenceIDs()))
+ var typed []Payload
+ var all []Payload
+ b.SubscribeCallback(InstanceDisposed, func(event Payload) { typed = append(typed, event) })
+ b.SubscribeAllCallback(func(event Payload) { all = append(all, event) })
+ stream := b.SubscribeAll()
+
+ b.Dispose()
+ if len(typed) != 0 {
+ t.Fatalf("typed disposed subscriber was called: %v", typed)
+ }
+ if len(all) != 1 || all[0].Type != InstanceDisposed.Type {
+ t.Fatalf("wildcard disposed events: %v", all)
+ }
+ properties := all[0].Properties.(map[string]any)
+ if properties["directory"] != "/d" {
+ t.Fatalf("disposed properties: %v", properties)
+ }
+ select {
+ case event := <-stream.C:
+ if event.Type != InstanceDisposed.Type {
+ t.Fatalf("stream event: %+v", event)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for disposal event")
+ }
+ select {
+ case _, ok := <-stream.C:
+ if ok {
+ t.Fatal("stream remained open")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for stream close")
+ }
+ b.Publish(InstanceDisposed, nil)
+ if len(all) != 1 {
+ t.Fatalf("publish after dispose delivered: %v", all)
+ }
+}
+
+func TestConcurrentPublishIsSafeAndComplete(t *testing.T) {
+ b := New(Context{}, WithIDGenerator(sequenceIDs()))
+ def := Define("test.concurrent", nil)
+ var mu sync.Mutex
+ count := 0
+ b.SubscribeCallback(def, func(Payload) {
+ mu.Lock()
+ count++
+ mu.Unlock()
+ })
+ var wg sync.WaitGroup
+ for range 100 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ b.Publish(def, nil)
+ }()
+ }
+ wg.Wait()
+ if count != 100 {
+ t.Fatalf("count = %d", count)
+ }
+}
diff --git a/internal/seniordev/bus/event.go b/internal/seniordev/bus/event.go
new file mode 100644
index 0000000000..90b0211e02
--- /dev/null
+++ b/internal/seniordev/bus/event.go
@@ -0,0 +1,66 @@
+//go:build !windows
+
+// Event-definition registry. Registry iteration preserves first-definition
+// order.
+package bus
+
+import "sync"
+
+// Definition identifies an event type and carries its consumer-supplied
+// property schema/descriptor.
+type Definition struct {
+ Type string `json:"type"`
+ Properties any `json:"properties"`
+}
+
+// PayloadDefinition describes one registered event type and its property
+// schema.
+type PayloadDefinition struct {
+ Type string `json:"type"`
+ Properties any `json:"properties"`
+ Identifier string `json:"identifier"`
+}
+
+var definitions = struct {
+ sync.RWMutex
+ order []string
+ byID map[string]Definition
+}{byID: make(map[string]Definition)}
+
+// Define registers and returns an event definition. Redefining a type updates
+// its schema without changing its original insertion position.
+func Define(eventType string, properties any) Definition {
+ definitions.Lock()
+ defer definitions.Unlock()
+ if _, exists := definitions.byID[eventType]; !exists {
+ definitions.order = append(definitions.order, eventType)
+ }
+ result := Definition{Type: eventType, Properties: properties}
+ definitions.byID[eventType] = result
+ return result
+}
+
+// Payloads returns the payload descriptors in registry order.
+func Payloads() []PayloadDefinition {
+ return payloadDefinitions()
+}
+
+func payloadDefinitions() []PayloadDefinition {
+ definitions.RLock()
+ defer definitions.RUnlock()
+ out := make([]PayloadDefinition, 0, len(definitions.order))
+ for _, eventType := range definitions.order {
+ def := definitions.byID[eventType]
+ out = append(out, PayloadDefinition{
+ Type: eventType,
+ Properties: def.Properties,
+ Identifier: "Event." + eventType,
+ })
+ }
+ return out
+}
+
+// InstanceDisposed is published to wildcard subscribers during Bus.Dispose.
+var InstanceDisposed = Define("server.instance.disposed", struct {
+ Directory string `json:"directory"`
+}{})
diff --git a/internal/seniordev/config/config.go b/internal/seniordev/config/config.go
new file mode 100644
index 0000000000..06123b9da7
--- /dev/null
+++ b/internal/seniordev/config/config.go
@@ -0,0 +1,584 @@
+//go:build !windows
+
+package config
+
+// Config file loading, JSONC parsing and merge.
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+// Info is the deliberately open config object. The known fields sit next to
+// several extensible record surfaces; retaining JSON values avoids lossy
+// re-encoding during merges.
+type Info map[string]any
+
+// OrderedEntry is one insertion-ordered JSON object field.
+type OrderedEntry struct {
+ Key string
+ Value any
+}
+
+// OrderedObject preserves source key order for the precedence-sensitive
+// permission and tools objects.
+type OrderedObject struct {
+ entries []OrderedEntry
+ index map[string]int
+}
+
+func NewOrderedObject() *OrderedObject {
+ return &OrderedObject{index: map[string]int{}}
+}
+
+func (o *OrderedObject) Set(key string, value any) {
+ if o.index == nil {
+ o.index = map[string]int{}
+ }
+ if index, ok := o.index[key]; ok {
+ o.entries[index].Value = value
+ return
+ }
+ o.index[key] = len(o.entries)
+ o.entries = append(o.entries, OrderedEntry{Key: key, Value: value})
+}
+
+func (o *OrderedObject) Get(key string) (any, bool) {
+ if o == nil {
+ return nil, false
+ }
+ index, ok := o.index[key]
+ if !ok {
+ return nil, false
+ }
+ return o.entries[index].Value, true
+}
+
+func (o *OrderedObject) Entries() []OrderedEntry {
+ if o == nil {
+ return nil
+ }
+ return append([]OrderedEntry(nil), o.entries...)
+}
+
+func (o *OrderedObject) MarshalJSON() ([]byte, error) {
+ var buffer bytes.Buffer
+ buffer.WriteByte('{')
+ for index, entry := range o.entries {
+ if index > 0 {
+ buffer.WriteByte(',')
+ }
+ key, err := json.Marshal(entry.Key)
+ if err != nil {
+ return nil, err
+ }
+ value, err := json.Marshal(entry.Value)
+ if err != nil {
+ return nil, err
+ }
+ buffer.Write(key)
+ buffer.WriteByte(':')
+ buffer.Write(value)
+ }
+ buffer.WriteByte('}')
+ return buffer.Bytes(), nil
+}
+
+type InvalidError struct {
+ Path string
+ Message string
+}
+
+func (e *InvalidError) Error() string {
+ if e.Message == "" {
+ return "invalid config: " + e.Path
+ }
+ return e.Message
+}
+
+func stripJSONC(input string) string {
+ out := []byte(input)
+ inString, escaped := false, false
+ for index := 0; index < len(out); index++ {
+ if inString {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if out[index] == '\\' {
+ escaped = true
+ } else if out[index] == '"' {
+ inString = false
+ }
+ continue
+ }
+ if out[index] == '"' {
+ inString = true
+ continue
+ }
+ if out[index] != '/' || index+1 >= len(out) {
+ continue
+ }
+ switch out[index+1] {
+ case '/':
+ for out[index] != '\n' && out[index] != '\r' {
+ out[index] = ' '
+ index++
+ if index >= len(out) {
+ break
+ }
+ }
+ case '*':
+ out[index], out[index+1] = ' ', ' '
+ index += 2
+ for index < len(out) {
+ if index+1 < len(out) && out[index] == '*' && out[index+1] == '/' {
+ out[index], out[index+1] = ' ', ' '
+ index++
+ break
+ }
+ if out[index] != '\n' && out[index] != '\r' {
+ out[index] = ' '
+ }
+ index++
+ }
+ }
+ }
+ // allowTrailingComma: true. Only commas whose next non-space byte closes an
+ // array/object are removed; string contents were left untouched above.
+ inString, escaped = false, false
+ for index := 0; index < len(out); index++ {
+ if inString {
+ if escaped {
+ escaped = false
+ } else if out[index] == '\\' {
+ escaped = true
+ } else if out[index] == '"' {
+ inString = false
+ }
+ continue
+ }
+ if out[index] == '"' {
+ inString = true
+ continue
+ }
+ if out[index] != ',' {
+ continue
+ }
+ next := index + 1
+ for next < len(out) && strings.ContainsRune(" \t\r\n", rune(out[next])) {
+ next++
+ }
+ if next < len(out) && (out[next] == '}' || out[next] == ']') {
+ out[index] = ' '
+ }
+ }
+ return string(out)
+}
+
+// ParseJSONC parses comments and trailing commas while preserving the source
+// path in errors.
+func ParseJSONC(text, source string) (any, error) {
+ return parseJSONC(text, source, false)
+}
+
+func parseJSONC(text, source string, preserveRoot bool) (any, error) {
+ decoder := json.NewDecoder(strings.NewReader(stripJSONC(text)))
+ decoder.UseNumber()
+ value, err := decodeOrderedJSON(decoder)
+ if err != nil {
+ return nil, &InvalidError{
+ Path: source,
+ Message: fmt.Sprintf("\n--- JSONC Input ---\n%s\n--- Errors ---\n%s\n--- End ---", text, err),
+ }
+ }
+ return materializeConfigJSON(value, preserveRoot), nil
+}
+
+func materializeConfigJSON(value any, preserve bool) any {
+ switch value := value.(type) {
+ case orderedJSONObject:
+ if preserve {
+ out := NewOrderedObject()
+ for _, field := range value {
+ out.Set(field.key, materializeConfigJSON(field.value, true))
+ }
+ return out
+ }
+ out := make(map[string]any, len(value))
+ for _, field := range value {
+ keepOrder := field.key == "permission" || field.key == "tools"
+ nested := materializeConfigJSON(field.value, keepOrder)
+ if field.key == "permission" {
+ if normalized, ok := NormalizePermission(nested); ok {
+ nested = normalized
+ }
+ }
+ out[field.key] = nested
+ }
+ return out
+ case []any:
+ out := make([]any, len(value))
+ for index, nested := range value {
+ out[index] = materializeConfigJSON(nested, preserve)
+ }
+ return out
+ default:
+ return value
+ }
+}
+
+func cloneValue(value any) any {
+ switch value := value.(type) {
+ case map[string]any:
+ out := make(map[string]any, len(value))
+ for key, nested := range value {
+ out[key] = cloneValue(nested)
+ }
+ return out
+ case *OrderedObject:
+ out := NewOrderedObject()
+ for _, entry := range value.Entries() {
+ out.Set(entry.Key, cloneValue(entry.Value))
+ }
+ return out
+ case []any:
+ out := make([]any, len(value))
+ for index, nested := range value {
+ out[index] = cloneValue(nested)
+ }
+ return out
+ default:
+ return value
+ }
+}
+
+func mergeValue(target, source any) any {
+ if left, leftOK := asOrderedObject(target); leftOK {
+ if right, rightOK := asOrderedObject(source); rightOK {
+ out := cloneValue(left).(*OrderedObject)
+ for _, entry := range right.Entries() {
+ if current, ok := out.Get(entry.Key); ok {
+ out.Set(entry.Key, mergeValue(current, entry.Value))
+ } else {
+ out.Set(entry.Key, cloneValue(entry.Value))
+ }
+ }
+ return out
+ }
+ }
+ left, leftOK := target.(map[string]any)
+ right, rightOK := source.(map[string]any)
+ if !leftOK || !rightOK {
+ return cloneValue(source)
+ }
+ out := cloneValue(left).(map[string]any)
+ for key, value := range right {
+ if current, ok := out[key]; ok {
+ out[key] = mergeValue(current, value)
+ } else {
+ out[key] = cloneValue(value)
+ }
+ }
+ return out
+}
+
+func asOrderedObject(value any) (*OrderedObject, bool) {
+ object, ok := value.(*OrderedObject)
+ return object, ok && object != nil
+}
+
+// Merge deep-merges source over target; the instructions array is the one
+// field that is concatenated (deduplicated) instead of replaced.
+func Merge(target, source Info) Info {
+ merged := mergeValue(map[string]any(target), map[string]any(source)).(map[string]any)
+ left, leftOK := target["instructions"].([]any)
+ right, rightOK := source["instructions"].([]any)
+ if leftOK && rightOK {
+ seen := map[any]struct{}{}
+ joined := make([]any, 0, len(left)+len(right))
+ for _, list := range [][]any{left, right} {
+ for _, value := range list {
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ joined = append(joined, value)
+ }
+ }
+ merged["instructions"] = joined
+ }
+ return Info(merged)
+}
+
+func normalizeLoadedConfig(value any) Info {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return Info{}
+ }
+ out := Info(cloneValue(object).(map[string]any))
+ delete(out, "theme")
+ delete(out, "keybinds")
+ delete(out, "tui")
+ return out
+}
+
+// NormalizePermission expands the action shorthand to the "*" rule.
+func NormalizePermission(value any) (*OrderedObject, bool) {
+ if action, ok := value.(string); ok {
+ if action != "ask" && action != "allow" && action != "deny" {
+ return nil, false
+ }
+ object := NewOrderedObject()
+ object.Set("*", action)
+ return object, true
+ }
+ object, ok := value.(*OrderedObject)
+ if !ok {
+ return nil, false
+ }
+ return object, true
+}
+
+func normalizeTools(info Info) {
+ tools, ok := asOrderedObject(info["tools"])
+ if !ok {
+ return
+ }
+ perms := NewOrderedObject()
+ for _, entry := range tools.Entries() {
+ tool, raw := entry.Key, entry.Value
+ enabled, _ := raw.(bool)
+ action := "deny"
+ if enabled {
+ action = "allow"
+ }
+ if tool == "write" || tool == "edit" || tool == "patch" {
+ perms.Set("edit", action)
+ } else {
+ perms.Set(tool, action)
+ }
+ }
+ if configured, ok := asOrderedObject(info["permission"]); ok {
+ perms = mergeValue(perms, configured).(*OrderedObject)
+ }
+ info["permission"] = perms
+}
+
+// LoadText expands substitutions, parses JSONC, and applies schema-level
+// normalizations used by the pipeline.
+func LoadText(text string, input SubstituteInput) (Info, error) {
+ input.Text = text
+ expanded, err := Substitute(input)
+ if err != nil {
+ return nil, err
+ }
+ value, err := ParseJSONC(expanded, input.Source)
+ if err != nil {
+ return nil, err
+ }
+ info := normalizeLoadedConfig(value)
+ if autoshare, ok := info["autoshare"].(bool); ok && autoshare {
+ if _, exists := info["share"]; !exists {
+ info["share"] = "auto"
+ }
+ }
+ return info, nil
+}
+
+// FileInDirectory returns the candidate paths for name, JSON before JSONC.
+func FileInDirectory(dir, name string) []string {
+ return []string{filepath.Join(dir, name+".json"), filepath.Join(dir, name+".jsonc")}
+}
+
+func withinOrSame(path, stop string) bool {
+ if stop == "" {
+ return true
+ }
+ rel, err := filepath.Rel(filepath.Clean(stop), filepath.Clean(path))
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+// ProjectFiles finds config files from the project boundary inward.
+func ProjectFiles(name, directory, worktree string) []string {
+ found := []string{}
+ current := filepath.Clean(directory)
+ stop := filepath.Clean(worktree)
+ for {
+ for _, candidate := range FileInDirectory(current, name) {
+ if _, err := os.Stat(candidate); err == nil {
+ found = append(found, candidate)
+ }
+ }
+ if current == stop || current == filepath.Dir(current) || !withinOrSame(filepath.Dir(current), stop) {
+ break
+ }
+ current = filepath.Dir(current)
+ }
+ for left, right := 0, len(found)-1; left < right; left, right = left+1, right-1 {
+ found[left], found[right] = found[right], found[left]
+ }
+ return found
+}
+
+// Loader reads and merges the config sources for one directory.
+type Loader struct {
+ GlobalDir string
+ Env Env
+}
+
+func (l Loader) globalDir() string {
+ if l.GlobalDir != "" {
+ return l.GlobalDir
+ }
+ if value, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok && value != "" {
+ return filepath.Join(value, "senior-dev")
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".config", "senior-dev")
+}
+
+func readOptional(path string, lookup Lookup) (Info, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return Info{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return LoadText(string(data), SubstituteInput{Path: path, Source: path, Lookup: lookup})
+}
+
+// Load merges the config sources, lowest precedence first: global files,
+// SENIOR_DEV_CONFIG, project files, SENIOR_DEV_CONFIG_CONTENT, SENIOR_DEV_PERMISSION.
+func (l Loader) Load(directory, worktree string) (Info, error) {
+ result := Info{}
+ lookup := l.Env.Get
+ for _, name := range []string{"config.json", "senior-dev.json", "senior-dev.jsonc"} {
+ next, err := readOptional(filepath.Join(l.globalDir(), name), lookup)
+ if err != nil {
+ return nil, err
+ }
+ result = Merge(result, next)
+ }
+ if custom, ok := l.Env.Get("SENIOR_DEV_CONFIG"); ok && custom != "" {
+ next, err := readOptional(custom, lookup)
+ if err != nil {
+ return nil, err
+ }
+ result = Merge(result, next)
+ }
+ if !l.Env.Enabled("SENIOR_DEV_DISABLE_PROJECT_CONFIG") {
+ for _, path := range ProjectFiles("senior-dev", directory, worktree) {
+ next, err := readOptional(path, lookup)
+ if err != nil {
+ return nil, err
+ }
+ result = Merge(result, next)
+ }
+ }
+ if content, ok := l.Env.Get("SENIOR_DEV_CONFIG_CONTENT"); ok && content != "" {
+ next, err := LoadText(content, SubstituteInput{
+ Dir: directory, Source: "SENIOR_DEV_CONFIG_CONTENT", Lookup: lookup,
+ })
+ if err != nil {
+ return nil, err
+ }
+ result = Merge(result, next)
+ }
+ if raw, ok := l.Env.Get("SENIOR_DEV_PERMISSION"); ok && raw != "" {
+ value, err := parseJSONC(raw, "SENIOR_DEV_PERMISSION", true)
+ if err != nil {
+ return nil, err
+ }
+ next, valid := NormalizePermission(value)
+ if !valid {
+ return nil, errors.New("SENIOR_DEV_PERMISSION must be a permission action or object")
+ }
+ current, _ := asOrderedObject(result["permission"])
+ if current == nil {
+ current = NewOrderedObject()
+ }
+ result["permission"] = mergeValue(current, next)
+ }
+ normalizeTools(result)
+ if l.Env.Enabled("SENIOR_DEV_DISABLE_AUTOCOMPACT") {
+ compaction, _ := result["compaction"].(map[string]any)
+ if compaction == nil {
+ compaction = map[string]any{}
+ }
+ compaction["auto"] = false
+ result["compaction"] = compaction
+ }
+ if l.Env.Enabled("SENIOR_DEV_DISABLE_PRUNE") {
+ compaction, _ := result["compaction"].(map[string]any)
+ if compaction == nil {
+ compaction = map[string]any{}
+ }
+ compaction["prune"] = false
+ result["compaction"] = compaction
+ }
+ return result, nil
+}
+
+// Service caches per-directory config and supports explicit invalidation.
+type Service struct {
+ loader Loader
+ mu sync.RWMutex
+ cache map[string]Info
+}
+
+func NewService(loader Loader) *Service {
+ return &Service{loader: loader, cache: map[string]Info{}}
+}
+
+func (s *Service) Get(directory, worktree string) (Info, error) {
+ key := filepath.Clean(directory)
+ s.mu.RLock()
+ if value, ok := s.cache[key]; ok {
+ s.mu.RUnlock()
+ return Info(cloneValue(map[string]any(value)).(map[string]any)), nil
+ }
+ s.mu.RUnlock()
+ value, err := s.loader.Load(directory, worktree)
+ if err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ s.cache[key] = value
+ s.mu.Unlock()
+ return Info(cloneValue(map[string]any(value)).(map[string]any)), nil
+}
+
+func (s *Service) Update(directory string, info Info) error {
+ path := filepath.Join(directory, "config.json")
+ existing, err := readOptional(path, s.loader.Env.Get)
+ if err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(Merge(existing, info), "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return err
+ }
+ s.Invalidate(directory)
+ return nil
+}
+
+func (s *Service) Invalidate(directory string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if directory == "" {
+ s.cache = map[string]Info{}
+ return
+ }
+ delete(s.cache, filepath.Clean(directory))
+}
diff --git a/internal/seniordev/config/env.go b/internal/seniordev/config/env.go
new file mode 100644
index 0000000000..b04df29e34
--- /dev/null
+++ b/internal/seniordev/config/env.go
@@ -0,0 +1,153 @@
+//go:build !windows
+
+// Package config is the configuration layer: project config files, the
+// SENIOR_DEV_* environment surface and their merge.
+package config
+
+import (
+ "os"
+ "strings"
+)
+
+// BoolMode identifies how a boolean environment variable is spelled. The
+// spellings are deliberately asymmetric (an opt-out reads "0", an opt-in reads
+// "1", a truthy flag reads "true"/"1"), so they are not replaced with
+// strconv.ParseBool.
+type BoolMode string
+
+const (
+ RawValue BoolMode = "raw"
+ OptInOne BoolMode = "opt-in-1"
+ OptOutZero BoolMode = "opt-out-0"
+ Truthy BoolMode = "truthy"
+)
+
+// VariableNames is the environment surface the config layer snapshots. A
+// variable not listed here is invisible to Env.Get.
+var VariableNames = []string{
+ "SENIOR_DEV_CLIENT",
+ "SENIOR_DEV_CONFIG",
+ "SENIOR_DEV_CONFIG_CONTENT",
+ "SENIOR_DEV_CONFIG_DIR",
+ "SENIOR_DEV_DISABLE_AUTOCOMPACT",
+ "SENIOR_DEV_DISABLE_MODELS_FETCH",
+ "SENIOR_DEV_DISABLE_PROJECT_CONFIG",
+ "SENIOR_DEV_DISABLE_PRUNE",
+ "SENIOR_DEV_EAGER_COMMIT",
+ "SENIOR_DEV_ENABLE_EXA",
+ "SENIOR_DEV_ENABLE_PARALLEL",
+ "SENIOR_DEV_ENABLE_QUESTION_TOOL",
+ "SENIOR_DEV_ENV_SIGNALS",
+ "SENIOR_DEV_EXPERIMENTAL",
+ "SENIOR_DEV_EXPERIMENTAL_EXA",
+ "SENIOR_DEV_EXPERIMENTAL_OXFMT",
+ "SENIOR_DEV_EXPERIMENTAL_PARALLEL",
+ "SENIOR_DEV_MODELS_PATH",
+ "SENIOR_DEV_MODELS_URL",
+ "SENIOR_DEV_OUTPUT_TOKEN_MAX",
+ "SENIOR_DEV_PERMISSION",
+ "SENIOR_DEV_SCRATCH_MAX_GB",
+ "SENIOR_DEV_SCRATCH_ROOT",
+ "SENIOR_DEV_SCRATCH_TTL_H",
+ "SENIOR_DEV_SHARED_BUILD_CACHE",
+ "SENIOR_DEV_WEBSEARCH_PROVIDER",
+ "SENIOR_DEV_MAX_COST_USD",
+ "SENIOR_DEV_MAX_WALL_H",
+}
+
+var boolModes = map[string]BoolMode{
+ // Exact opt-outs.
+ "SENIOR_DEV_EAGER_COMMIT": OptOutZero,
+ "SENIOR_DEV_ENV_SIGNALS": OptOutZero,
+
+ // Exact opt-ins.
+ "SENIOR_DEV_SHARED_BUILD_CACHE": OptInOne,
+
+ // Case-insensitive truthy flags.
+ "SENIOR_DEV_DISABLE_AUTOCOMPACT": Truthy,
+ "SENIOR_DEV_DISABLE_MODELS_FETCH": Truthy,
+ "SENIOR_DEV_DISABLE_PROJECT_CONFIG": Truthy,
+ "SENIOR_DEV_DISABLE_PRUNE": Truthy,
+ "SENIOR_DEV_ENABLE_EXA": Truthy,
+ "SENIOR_DEV_ENABLE_PARALLEL": Truthy,
+ "SENIOR_DEV_ENABLE_QUESTION_TOOL": Truthy,
+ "SENIOR_DEV_EXPERIMENTAL": Truthy,
+ "SENIOR_DEV_EXPERIMENTAL_EXA": Truthy,
+ "SENIOR_DEV_EXPERIMENTAL_OXFMT": Truthy,
+ "SENIOR_DEV_EXPERIMENTAL_PARALLEL": Truthy,
+}
+
+// Mode returns the parsing mode for name. Non-boolean variables retain their
+// raw string value.
+func Mode(name string) BoolMode {
+ if mode, ok := boolModes[name]; ok {
+ return mode
+ }
+ return RawValue
+}
+
+// ParseBoolean applies one of the exact boolean comparisons. raw=nil
+// represents an absent environment entry.
+func ParseBoolean(mode BoolMode, raw *string) bool {
+ value := ""
+ if raw != nil {
+ value = *raw
+ }
+ switch mode {
+ case OptInOne:
+ return value == "1"
+ case OptOutZero:
+ return value != "0"
+ case Truthy:
+ lower := strings.ToLower(value)
+ return lower == "true" || lower == "1"
+ default:
+ return false
+ }
+}
+
+// Lookup is the minimal environment read boundary used by Config.
+type Lookup func(string) (string, bool)
+
+// Env snapshots an environment without mutating the process-global map.
+type Env struct {
+ values map[string]string
+}
+
+// NewEnv snapshots lookup for the declared variables.
+func NewEnv(lookup Lookup) Env {
+ if lookup == nil {
+ lookup = os.LookupEnv
+ }
+ values := make(map[string]string, len(VariableNames))
+ for _, name := range VariableNames {
+ if value, ok := lookup(name); ok {
+ values[name] = value
+ }
+ }
+ return Env{values: values}
+}
+
+// Get returns a raw value and preserves absent versus explicitly empty.
+func (e Env) Get(name string) (string, bool) {
+ value, ok := e.values[name]
+ return value, ok
+}
+
+// Enabled parses name according to its declared mode.
+func (e Env) Enabled(name string) bool {
+ value, ok := e.Get(name)
+ if !ok {
+ return ParseBoolean(Mode(name), nil)
+ }
+ return ParseBoolean(Mode(name), &value)
+}
+
+// All returns a defensive copy.
+func (e Env) All() map[string]string {
+ out := make(map[string]string, len(e.values))
+ for key, value := range e.values {
+ out[key] = value
+ }
+ return out
+}
diff --git a/internal/seniordev/config/helpers.go b/internal/seniordev/config/helpers.go
new file mode 100644
index 0000000000..ef32d598ee
--- /dev/null
+++ b/internal/seniordev/config/helpers.go
@@ -0,0 +1,87 @@
+//go:build !windows
+
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// SubstituteInput is the input to Substitute: the config text plus where it
+// came from, which anchors relative {file:...} references.
+type SubstituteInput struct {
+ Text string
+ Path string
+ Dir string
+ Source string
+ Lookup Lookup
+}
+
+var envToken = regexp.MustCompile(`\{env:([^}]+)\}`)
+var fileToken = regexp.MustCompile(`\{file:[^}]+\}`)
+
+// Substitute applies {env:VAR} and {file:path} substitutions. A file
+// reference on a line that starts with // is left alone; a missing file is an
+// error.
+func Substitute(input SubstituteInput) (string, error) {
+ lookup := input.Lookup
+ if lookup == nil {
+ lookup = os.LookupEnv
+ }
+ text := envToken.ReplaceAllStringFunc(input.Text, func(token string) string {
+ name := token[len("{env:") : len(token)-1]
+ value, _ := lookup(name)
+ return value
+ })
+ matches := fileToken.FindAllStringIndex(text, -1)
+ if len(matches) == 0 {
+ return text, nil
+ }
+ configDir := input.Dir
+ if input.Path != "" {
+ configDir = filepath.Dir(input.Path)
+ }
+ var out strings.Builder
+ cursor := 0
+ for _, match := range matches {
+ token := text[match[0]:match[1]]
+ out.WriteString(text[cursor:match[0]])
+ lineStart := strings.LastIndex(text[:match[0]], "\n") + 1
+ if strings.HasPrefix(strings.TrimLeftFunc(text[lineStart:match[0]], unicode.IsSpace), "//") {
+ out.WriteString(token)
+ cursor = match[1]
+ continue
+ }
+ file := strings.TrimSuffix(strings.TrimPrefix(token, "{file:"), "}")
+ if strings.HasPrefix(file, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ file = filepath.Join(home, file[2:])
+ }
+ }
+ if !filepath.IsAbs(file) {
+ file = filepath.Join(configDir, file)
+ }
+ file = filepath.Clean(file)
+ data, err := os.ReadFile(file)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf(`bad file reference: %q %s does not exist`, token, file)
+ }
+ return "", fmt.Errorf(`bad file reference: %q`, token)
+ }
+ quoted, _ := jsonutil.Marshal(strings.TrimSpace(string(data)))
+ if len(quoted) >= 2 {
+ out.Write(quoted[1 : len(quoted)-1])
+ }
+ cursor = match[1]
+ }
+ out.WriteString(text[cursor:])
+ return out.String(), nil
+}
diff --git a/internal/seniordev/config/loader_test.go b/internal/seniordev/config/loader_test.go
new file mode 100644
index 0000000000..54665f7bdc
--- /dev/null
+++ b/internal/seniordev/config/loader_test.go
@@ -0,0 +1,43 @@
+//go:build !windows
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoaderMergesProjectAndPermissionEnvironment(t *testing.T) {
+ workspace := t.TempDir()
+ global := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(`{
+ "instructions": ["PROJECT.md"],
+ "permission": {"edit": "allow"}
+}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ values := map[string]string{
+ "SENIOR_DEV_PERMISSION": `{"edit":"deny","read":"ask"}`,
+ }
+ env := NewEnv(func(name string) (string, bool) {
+ value, ok := values[name]
+ return value, ok
+ })
+
+ // The once-per-run loader layers env over project config.
+ loaded, err := (Loader{GlobalDir: global, Env: env}).Load(workspace, workspace)
+ if err != nil {
+ t.Fatal(err)
+ }
+ permission, _ := loaded["permission"].(*OrderedObject)
+ edit, _ := permission.Get("edit")
+ read, _ := permission.Get("read")
+ if edit != "deny" || read != "ask" {
+ t.Fatalf("permission merge = %#v", permission)
+ }
+ instructions, _ := loaded["instructions"].([]any)
+ if len(instructions) != 1 || instructions[0] != "PROJECT.md" {
+ t.Fatalf("instructions = %#v", instructions)
+ }
+}
diff --git a/internal/seniordev/config/orderedjson.go b/internal/seniordev/config/orderedjson.go
new file mode 100644
index 0000000000..d3a9419ae0
--- /dev/null
+++ b/internal/seniordev/config/orderedjson.go
@@ -0,0 +1,65 @@
+//go:build !windows
+
+package config
+
+import (
+ "encoding/json"
+ "errors"
+)
+
+// orderedJSONField is one member of an object decoded with its source order
+// intact. Config keeps source order for the permission and tools blocks,
+// where rule order is meaningful.
+type orderedJSONField struct {
+ key string
+ value any
+}
+
+type orderedJSONObject []orderedJSONField
+
+// decodeOrderedJSON decodes the next value from decoder, keeping object
+// members in source order. Numbers arrive as json.Number.
+func decodeOrderedJSON(decoder *json.Decoder) (any, error) {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ delimiter, isDelimiter := token.(json.Delim)
+ if !isDelimiter {
+ return token, nil
+ }
+ switch delimiter {
+ case '{':
+ object := orderedJSONObject{}
+ for decoder.More() {
+ keyToken, keyErr := decoder.Token()
+ if keyErr != nil {
+ return nil, keyErr
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return nil, errors.New("JSON object key is not a string")
+ }
+ value, valueErr := decodeOrderedJSON(decoder)
+ if valueErr != nil {
+ return nil, valueErr
+ }
+ object = append(object, orderedJSONField{key: key, value: value})
+ }
+ _, err = decoder.Token()
+ return object, err
+ case '[':
+ array := []any{}
+ for decoder.More() {
+ value, valueErr := decodeOrderedJSON(decoder)
+ if valueErr != nil {
+ return nil, valueErr
+ }
+ array = append(array, value)
+ }
+ _, err = decoder.Token()
+ return array, err
+ default:
+ return nil, errors.New("unexpected JSON delimiter")
+ }
+}
diff --git a/internal/seniordev/core/filesystem.go b/internal/seniordev/core/filesystem.go
new file mode 100644
index 0000000000..bfe96e113b
--- /dev/null
+++ b/internal/seniordev/core/filesystem.go
@@ -0,0 +1,502 @@
+//go:build !windows
+
+// Application filesystem. Plain writes and write-with-parent-directory retry
+// are distinct methods.
+package core
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "mime"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// FileSystemError wraps operations implemented outside the basic os package.
+type FileSystemError struct {
+ Method string
+ Cause error
+}
+
+func (e *FileSystemError) Error() string {
+ if e.Cause == nil {
+ return "FileSystemError: " + e.Method
+ }
+ return "FileSystemError: " + e.Method + ": " + e.Cause.Error()
+}
+
+func (e *FileSystemError) Unwrap() error { return e.Cause }
+
+// DirEntry is the portable directory-entry shape from AppFileSystem.
+type DirEntry struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+// GlobOptions configures Glob.
+type GlobOptions struct {
+ Cwd string
+ Absolute bool
+ Include string // "file" (default) or "all"
+ Dot bool
+ Symlink bool
+}
+
+// AppFileSystem is the concrete application filesystem service.
+type AppFileSystem struct{}
+
+// NewFileSystem constructs the default OS-backed service.
+func NewFileSystem() *AppFileSystem { return &AppFileSystem{} }
+
+// ExistsSafe swallows all stat failures.
+func (f *AppFileSystem) ExistsSafe(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// ReadFileStringSafe maps only NotExist to an absent result.
+func (f *AppFileSystem) ReadFileStringSafe(path string) (string, bool, error) {
+ data, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, err
+ }
+ return string(data), true, nil
+}
+
+// IsDir returns false on every stat failure.
+func (f *AppFileSystem) IsDir(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
+// IsFile returns false on every stat failure.
+func (f *AppFileSystem) IsFile(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.Mode().IsRegular()
+}
+
+// ReadDirectoryEntries preserves the operating system's readdir order.
+func (f *AppFileSystem) ReadDirectoryEntries(path string) ([]DirEntry, error) {
+ dir, err := os.Open(path)
+ if err != nil {
+ return nil, &FileSystemError{Method: "readDirectoryEntries", Cause: err}
+ }
+ defer dir.Close()
+ entries, err := dir.Readdir(-1)
+ if err != nil {
+ return nil, &FileSystemError{Method: "readDirectoryEntries", Cause: err}
+ }
+ out := make([]DirEntry, 0, len(entries))
+ for _, entry := range entries {
+ entryType := "other"
+ switch {
+ case entry.IsDir():
+ entryType = "directory"
+ case entry.Mode()&os.ModeSymlink != 0:
+ entryType = "symlink"
+ case entry.Mode().IsRegular():
+ entryType = "file"
+ }
+ out = append(out, DirEntry{Name: entry.Name(), Type: entryType})
+ }
+ return out, nil
+}
+
+// ReadJSON decodes a JSON file into dst; numbers decode as float64.
+func (f *AppFileSystem) ReadJSON(path string, dst any) error {
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ decoder := json.NewDecoder(file)
+ return decoder.Decode(dst)
+}
+
+// WriteJSON does not create parent directories.
+func (f *AppFileSystem) WriteJSON(path string, data any, mode ...fs.FileMode) error {
+ content, err := jsonutil.MarshalIndent(data)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, content, 0o666); err != nil {
+ return err
+ }
+ if len(mode) > 0 && mode[0] != 0 {
+ return os.Chmod(path, mode[0])
+ }
+ return nil
+}
+
+// EnsureDir creates path recursively.
+func (f *AppFileSystem) EnsureDir(path string) error {
+ return os.MkdirAll(path, 0o777)
+}
+
+// WriteWithDirs retries a missing-parent write after creating the parent.
+func (f *AppFileSystem) WriteWithDirs(path string, content []byte, mode ...fs.FileMode) error {
+ err := os.WriteFile(path, content, 0o666)
+ if errors.Is(err, fs.ErrNotExist) {
+ if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o777); mkdirErr != nil {
+ return mkdirErr
+ }
+ err = os.WriteFile(path, content, 0o666)
+ }
+ if err != nil {
+ return err
+ }
+ if len(mode) > 0 && mode[0] != 0 {
+ return os.Chmod(path, mode[0])
+ }
+ return nil
+}
+
+// WriteStringWithDirs is the string overload of WriteWithDirs.
+func (f *AppFileSystem) WriteStringWithDirs(path, content string, mode ...fs.FileMode) error {
+ return f.WriteWithDirs(path, []byte(content), mode...)
+}
+
+// Glob scans from options.Cwd using minimatch-style ** path segments.
+func (f *AppFileSystem) Glob(pattern string, options ...GlobOptions) ([]string, error) {
+ opt := GlobOptions{}
+ if len(options) > 0 {
+ opt = options[0]
+ }
+ cwd := opt.Cwd
+ if cwd == "" {
+ var err error
+ cwd, err = os.Getwd()
+ if err != nil {
+ return nil, &FileSystemError{Method: "glob", Cause: err}
+ }
+ }
+ out := []string{}
+ err := walkGlob(cwd, opt.Symlink, func(path string, entry fs.DirEntry) error {
+ if path == cwd {
+ return nil
+ }
+ rel, err := filepath.Rel(cwd, path)
+ if err != nil {
+ return err
+ }
+ slashRel := filepath.ToSlash(rel)
+ if !opt.Dot && hasDotSegment(slashRel) && !patternMentionsDot(pattern) {
+ if entry.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if !GlobMatch(pattern, slashRel) {
+ return nil
+ }
+ if opt.Include != "all" && entry.IsDir() {
+ return nil
+ }
+ if opt.Absolute {
+ absolute, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ out = append(out, absolute)
+ } else {
+ out = append(out, filepath.FromSlash(slashRel))
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, &FileSystemError{Method: "glob", Cause: err}
+ }
+ return out, nil
+}
+
+func walkGlob(root string, followSymlinks bool, visit func(string, fs.DirEntry) error) error {
+ seen := map[string]bool{}
+ var walk func(string) error
+ walk = func(path string) error {
+ real := path
+ if followSymlinks {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ real = resolved
+ }
+ if seen[real] {
+ return nil
+ }
+ seen[real] = true
+ }
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ child := filepath.Join(path, entry.Name())
+ err := visit(child, entry)
+ if errors.Is(err, fs.SkipDir) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ isDir := entry.IsDir()
+ if !isDir && followSymlinks && entry.Type()&os.ModeSymlink != 0 {
+ if info, err := os.Stat(child); err == nil {
+ isDir = info.IsDir()
+ }
+ }
+ if isDir {
+ if err := walk(child); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ }
+ return walk(root)
+}
+
+// GlobMatch matches a slash-separated path against a pattern whose "**"
+// segment spans any number of directories. Dot files are not special here.
+func GlobMatch(pattern, path string) bool {
+ patternParts := splitSlash(pattern)
+ pathParts := splitSlash(path)
+ var match func(int, int) bool
+ match = func(pi, si int) bool {
+ if pi == len(patternParts) {
+ return si == len(pathParts)
+ }
+ if patternParts[pi] == "**" {
+ if match(pi+1, si) {
+ return true
+ }
+ return si < len(pathParts) && match(pi, si+1)
+ }
+ if si >= len(pathParts) {
+ return false
+ }
+ ok, err := filepath.Match(patternParts[pi], pathParts[si])
+ return err == nil && ok && match(pi+1, si+1)
+ }
+ return match(0, 0)
+}
+
+func splitSlash(value string) []string {
+ value = strings.ReplaceAll(value, "\\", "/")
+ value = strings.TrimPrefix(value, "./")
+ return strings.Split(value, "/")
+}
+
+func hasDotSegment(path string) bool {
+ for _, part := range splitSlash(path) {
+ if strings.HasPrefix(part, ".") {
+ return true
+ }
+ }
+ return false
+}
+
+func patternMentionsDot(pattern string) bool {
+ for _, part := range splitSlash(pattern) {
+ if strings.HasPrefix(part, ".") {
+ return true
+ }
+ }
+ return false
+}
+
+// FindUp finds target at start and each parent, nearest first.
+func (f *AppFileSystem) FindUp(target, start string, stop ...string) ([]string, error) {
+ return f.Up(UpOptions{Targets: []string{target}, Start: start, Stop: first(stop)})
+}
+
+// UpOptions configures Up.
+type UpOptions struct {
+ Targets []string
+ Start string
+ Stop string
+}
+
+// Up finds all target names at every ancestor.
+func (f *AppFileSystem) Up(options UpOptions) ([]string, error) {
+ result := []string{}
+ current := options.Start
+ for {
+ for _, target := range options.Targets {
+ search := filepath.Join(current, target)
+ if _, err := os.Stat(search); err == nil {
+ result = append(result, search)
+ }
+ }
+ if options.Stop == current {
+ break
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ current = parent
+ }
+ return result, nil
+}
+
+// GlobUp scans each ancestor and swallows per-directory glob errors.
+func (f *AppFileSystem) GlobUp(pattern, start string, stop ...string) ([]string, error) {
+ result := []string{}
+ current := start
+ stopAt := first(stop)
+ for {
+ matches, err := f.Glob(pattern, GlobOptions{Cwd: current, Absolute: true, Dot: true})
+ if err == nil {
+ result = append(result, matches...)
+ }
+ if stopAt == current {
+ break
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ current = parent
+ }
+ return result, nil
+}
+
+func first(values []string) string {
+ if len(values) == 0 {
+ return ""
+ }
+ return values[0]
+}
+
+// MimeType returns the media type for path's extension, or
+// application/octet-stream when it is unknown.
+func MimeType(path string) string {
+ extension := strings.ToLower(filepath.Ext(path))
+ switch extension {
+ case ".md", ".markdown":
+ return "text/markdown"
+ case ".ts":
+ return "video/mp2t"
+ case ".js", ".mjs":
+ return "text/javascript"
+ case ".json":
+ return "application/json"
+ case ".yaml", ".yml":
+ return "text/yaml"
+ case ".wasm":
+ return "application/wasm"
+ case ".tsx":
+ return "application/octet-stream"
+ }
+ if value := mime.TypeByExtension(extension); value != "" {
+ return strings.TrimSpace(strings.Split(value, ";")[0])
+ }
+ return "application/octet-stream"
+}
+
+// NormalizePath canonicalizes Windows paths; it is a no-op on other systems.
+func NormalizePath(path string) string {
+ if runtime.GOOS != "windows" {
+ return path
+ }
+ resolved, _ := filepath.Abs(WindowsPath(path))
+ if real, err := filepath.EvalSymlinks(resolved); err == nil {
+ return real
+ }
+ return resolved
+}
+
+// NormalizePathPattern preserves a terminal wildcard during normalization.
+func NormalizePathPattern(path string) string {
+ if runtime.GOOS != "windows" {
+ return path
+ }
+ if path == "*" {
+ return path
+ }
+ normalized := strings.ReplaceAll(path, "\\", "/")
+ if !strings.HasSuffix(normalized, "/*") {
+ return NormalizePath(path)
+ }
+ dir := strings.TrimSuffix(normalized, "/*")
+ if len(dir) == 2 && dir[1] == ':' {
+ dir += `\`
+ }
+ return filepath.Join(NormalizePath(dir), "*")
+}
+
+// Resolve returns the real absolute path or the normalized absolute path when
+// the target does not exist.
+func Resolve(path string) (string, error) {
+ resolved, err := filepath.Abs(WindowsPath(path))
+ if err != nil {
+ return "", err
+ }
+ real, err := filepath.EvalSymlinks(resolved)
+ if err == nil {
+ return NormalizePath(real), nil
+ }
+ if errors.Is(err, fs.ErrNotExist) {
+ return NormalizePath(resolved), nil
+ }
+ return "", err
+}
+
+// WindowsPath translates common POSIX drive spellings on Windows.
+func WindowsPath(path string) string {
+ if runtime.GOOS != "windows" {
+ return path
+ }
+ return windowsPath(path)
+}
+
+func windowsPath(path string) string {
+ slash := strings.ReplaceAll(path, "\\", "/")
+ var rest string
+ var drive byte
+ switch {
+ case len(slash) >= 3 && slash[0] == '/' && isASCIIAlpha(slash[1]) && slash[2] == ':':
+ if len(slash) > 3 && slash[3] != '/' {
+ return path
+ }
+ drive, rest = slash[1], slash[3:]
+ case len(slash) >= 2 && slash[0] == '/' && isASCIIAlpha(slash[1]) && (len(slash) == 2 || slash[2] == '/'):
+ drive, rest = slash[1], slash[2:]
+ case strings.HasPrefix(slash, "/cygdrive/") && len(slash) >= 11 && isASCIIAlpha(slash[10]) &&
+ (len(slash) == 11 || slash[11] == '/'):
+ drive, rest = slash[10], slash[11:]
+ case strings.HasPrefix(slash, "/mnt/") && len(slash) >= 6 && isASCIIAlpha(slash[5]) &&
+ (len(slash) == 6 || slash[6] == '/'):
+ drive, rest = slash[5], slash[6:]
+ default:
+ return path
+ }
+ if drive >= 'a' && drive <= 'z' {
+ drive -= 'a' - 'A'
+ }
+ return string(drive) + ":/" + strings.TrimPrefix(rest, "/")
+}
+
+func isASCIIAlpha(value byte) bool {
+ return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z'
+}
+
+// Overlaps reports whether either path is within the other.
+func Overlaps(a, b string) bool {
+ relA, _ := filepath.Rel(a, b)
+ relB, _ := filepath.Rel(b, a)
+ return relA == "" || !strings.HasPrefix(relA, "..") || relB == "" || !strings.HasPrefix(relB, "..")
+}
+
+// Contains reports whether child is under parent: any relative path that
+// starts with ".." counts as outside.
+func Contains(parent, child string) bool {
+ relative, _ := filepath.Rel(parent, child)
+ return !strings.HasPrefix(relative, "..")
+}
diff --git a/internal/seniordev/core/filesystem_test.go b/internal/seniordev/core/filesystem_test.go
new file mode 100644
index 0000000000..3085c812d8
--- /dev/null
+++ b/internal/seniordev/core/filesystem_test.go
@@ -0,0 +1,102 @@
+//go:build !windows
+
+package core
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "testing"
+)
+
+func TestFileSystemWritesReadsAndDirectoryEntries(t *testing.T) {
+ root := t.TempDir()
+ filesystem := NewFileSystem()
+ nested := filepath.Join(root, "a", "b.json")
+ if err := filesystem.WriteJSON(nested, map[string]any{"x": 1}); err == nil {
+ t.Fatal("WriteJSON unexpectedly created parents")
+ }
+ if err := filesystem.WriteStringWithDirs(nested, `{"x":1}`, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var value map[string]any
+ if err := filesystem.ReadJSON(nested, &value); err != nil {
+ t.Fatal(err)
+ }
+ if value["x"].(float64) != 1 {
+ t.Fatalf("read JSON: %#v", value)
+ }
+ info, err := os.Stat(nested)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("mode = %o", info.Mode().Perm())
+ }
+ entries, err := filesystem.ReadDirectoryEntries(filepath.Join(root, "a"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 || entries[0] != (DirEntry{Name: "b.json", Type: "file"}) {
+ t.Fatalf("entries: %#v", entries)
+ }
+}
+
+func TestFileSystemUpAndGlob(t *testing.T) {
+ root := t.TempDir()
+ filesystem := NewFileSystem()
+ for _, rel := range []string{"package.json", "a/config.json", "a/b/file.go", "a/b/.hidden.go", ".root-hidden"} {
+ path := filepath.Join(root, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ start := filepath.Join(root, "a", "b")
+ found, err := filesystem.Up(UpOptions{Targets: []string{"package.json", "config.json"}, Start: start})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantFound := []string{filepath.Join(root, "a", "config.json"), filepath.Join(root, "package.json")}
+ if !reflect.DeepEqual(found, wantFound) {
+ t.Fatalf("up = %v, want %v", found, wantFound)
+ }
+
+ matches, err := filesystem.Glob("**/*.go", GlobOptions{Cwd: root})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sort.Strings(matches)
+ if !reflect.DeepEqual(matches, []string{filepath.Join("a", "b", "file.go")}) {
+ t.Fatalf("glob without dot: %v", matches)
+ }
+ matches, err = filesystem.Glob("**/*.go", GlobOptions{Cwd: root, Dot: true, Absolute: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sort.Strings(matches)
+ want := []string{filepath.Join(root, "a", "b", ".hidden.go"), filepath.Join(root, "a", "b", "file.go")}
+ sort.Strings(want)
+ if !reflect.DeepEqual(matches, want) {
+ t.Fatalf("glob dot: %v, want %v", matches, want)
+ }
+}
+
+func TestWindowsPathTransform(t *testing.T) {
+ cases := map[string]string{
+ "/c/x": "C:/x",
+ "/c": "C:/",
+ "/c:/x": "C:/x",
+ "/cygdrive/d/x": "D:/x",
+ "/mnt/e/x": "E:/x",
+ "/code": "/code",
+ }
+ for input, want := range cases {
+ if got := windowsPath(input); got != want {
+ t.Errorf("windowsPath(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
diff --git a/internal/seniordev/core/npm.go b/internal/seniordev/core/npm.go
new file mode 100644
index 0000000000..cfb5725c50
--- /dev/null
+++ b/internal/seniordev/core/npm.go
@@ -0,0 +1,402 @@
+//go:build !windows
+
+// Npm package helper. Installation is behind the narrow Reifier interface;
+// the default implementation invokes npm with save/ignore-scripts settings.
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+)
+
+// InstallFailedError is the tagged npm installation failure.
+type InstallFailedError struct {
+ Add []string
+ Dir string
+ Cause error
+}
+
+func (e *InstallFailedError) Error() string {
+ if e.Cause == nil {
+ return fmt.Sprintf("NpmInstallFailedError: dir=%s add=%v", e.Dir, e.Add)
+ }
+ return fmt.Sprintf("NpmInstallFailedError: dir=%s add=%v: %v", e.Dir, e.Add, e.Cause)
+}
+
+func (e *InstallFailedError) Unwrap() error { return e.Cause }
+
+// EntryPoint is the installed package directory and optional import entry.
+type EntryPoint struct {
+ Directory string `json:"directory"`
+ Entrypoint *string `json:"entrypoint"`
+}
+
+// PackageRequest is an extra dependency requested by Install.
+type PackageRequest struct {
+ Name string
+ Version string
+}
+
+// ReifiedNode is the package a reify installed.
+type ReifiedNode struct {
+ Name string
+ Path string
+}
+
+// Reifier performs one npm reify operation.
+type Reifier interface {
+ Reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error)
+}
+
+// Npm is an npm package-cache service.
+type Npm struct {
+ CacheDir string
+ FS *AppFileSystem
+ Reifier Reifier
+
+ mu sync.Mutex
+ locks map[string]*sync.Mutex
+}
+
+// NewNpm constructs an npm helper rooted at cacheDir.
+func NewNpm(cacheDir string, reifier Reifier) *Npm {
+ fs := NewFileSystem()
+ if reifier == nil {
+ reifier = &commandReifier{spawner: NewSpawner()}
+ }
+ return &Npm{CacheDir: cacheDir, FS: fs, Reifier: reifier, locks: make(map[string]*sync.Mutex)}
+}
+
+func (n *Npm) directory(pkg string) string {
+ return filepath.Join(n.CacheDir, "packages", Sanitize(pkg))
+}
+
+func (n *Npm) lock(dir string) func() {
+ n.mu.Lock()
+ lock := n.locks[dir]
+ if lock == nil {
+ lock = &sync.Mutex{}
+ n.locks[dir] = lock
+ }
+ n.mu.Unlock()
+ lock.Lock()
+ return lock.Unlock
+}
+
+func (n *Npm) reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error) {
+ unlock := n.lock(dir)
+ defer unlock()
+ node, err := n.Reifier.Reify(ctx, dir, add)
+ if err != nil {
+ return nil, &InstallFailedError{Add: append([]string(nil), add...), Dir: dir, Cause: err}
+ }
+ return node, nil
+}
+
+// Add installs pkg in its isolated package cache.
+func (n *Npm) Add(ctx context.Context, pkg string) (EntryPoint, error) {
+ dir := n.directory(pkg)
+ name := packageName(pkg)
+ installed := filepath.Join(dir, "node_modules", filepath.FromSlash(name))
+ if n.FS.ExistsSafe(installed) {
+ return resolveEntryPoint(name, installed), nil
+ }
+ first, err := n.reify(ctx, dir, []string{pkg})
+ if err != nil {
+ return EntryPoint{}, err
+ }
+ if first == nil {
+ result := resolveEntryPoint(name, installed)
+ if result.Entrypoint != nil {
+ return result, nil
+ }
+ return EntryPoint{}, &InstallFailedError{Add: []string{pkg}, Dir: dir}
+ }
+ return resolveEntryPoint(first.Name, first.Path), nil
+}
+
+// Install reifies dir when node_modules is absent or the root lockfile omits a
+// declared dependency. An unwritable directory is silently skipped.
+func (n *Npm) Install(ctx context.Context, dir string, input ...[]PackageRequest) error {
+ if !writable(dir) {
+ return nil
+ }
+ requests := []PackageRequest{}
+ if len(input) > 0 {
+ requests = input[0]
+ }
+ add := make([]string, 0, len(requests))
+ for _, pkg := range requests {
+ if pkg.Version == "" {
+ add = append(add, pkg.Name)
+ } else {
+ add = append(add, pkg.Name+"@"+pkg.Version)
+ }
+ }
+ if !n.FS.ExistsSafe(filepath.Join(dir, "node_modules")) {
+ _, err := n.reify(ctx, dir, add)
+ return err
+ }
+
+ pkg := readJSONObject(filepath.Join(dir, "package.json"))
+ lock := readJSONObject(filepath.Join(dir, "package-lock.json"))
+ declared := map[string]bool{}
+ for _, key := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} {
+ for name := range objectMap(pkg[key]) {
+ declared[name] = true
+ }
+ }
+ for _, request := range requests {
+ declared[request.Name] = true
+ }
+ root := objectMap(objectMap(lock["packages"])[""])
+ locked := map[string]bool{}
+ for _, key := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} {
+ for name := range objectMap(root[key]) {
+ locked[name] = true
+ }
+ }
+ for name := range declared {
+ if !locked[name] {
+ _, err := n.reify(ctx, dir, add)
+ return err
+ }
+ }
+ return nil
+}
+
+// Which finds a package-provided executable, repairing the isolated install
+// once when no bin exists.
+func (n *Npm) Which(ctx context.Context, pkg string, bin ...string) (string, bool) {
+ dir := n.directory(pkg)
+ binDir := filepath.Join(dir, "node_modules", ".bin")
+ hint := ""
+ if len(bin) > 0 {
+ hint = bin[0]
+ }
+ pick := func() (string, bool) {
+ directory, err := os.Open(binDir)
+ if err != nil {
+ return "", false
+ }
+ files, err := directory.Readdirnames(-1)
+ _ = directory.Close()
+ if err != nil || len(files) == 0 {
+ return "", false
+ }
+ if hint != "" {
+ for _, file := range files {
+ if file == hint {
+ return file, true
+ }
+ }
+ return "", false
+ }
+ if len(files) == 1 {
+ return files[0], true
+ }
+ var manifest struct {
+ Bin json.RawMessage `json:"bin"`
+ }
+ if data, err := os.ReadFile(filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")); err == nil &&
+ json.Unmarshal(data, &manifest) == nil && len(manifest.Bin) > 0 && string(manifest.Bin) != "null" {
+ var path string
+ if json.Unmarshal(manifest.Bin, &path) == nil {
+ return unscoped(pkg), true
+ }
+ order := orderedObjectKeys(manifest.Bin)
+ if len(order) == 1 {
+ return order[0], true
+ }
+ name := unscoped(pkg)
+ for _, key := range order {
+ if key == name {
+ return name, true
+ }
+ }
+ if len(order) > 0 {
+ return order[0], true
+ }
+ }
+ return files[0], true
+ }
+ if selected, ok := pick(); ok {
+ return filepath.Join(binDir, selected), true
+ }
+ _ = os.Remove(filepath.Join(dir, "package-lock.json"))
+ if _, err := n.Add(ctx, pkg); err != nil {
+ return "", false
+ }
+ selected, ok := pick()
+ if !ok {
+ return "", false
+ }
+ return filepath.Join(binDir, selected), true
+}
+
+// Sanitize replaces Windows-illegal package path characters. It is a no-op
+// on non-Windows platforms.
+func Sanitize(pkg string) string {
+ return sanitizeForPlatform(pkg, runtime.GOOS)
+}
+
+func sanitizeForPlatform(pkg, goos string) string {
+ if goos != "windows" {
+ return pkg
+ }
+ illegal := `<>:"|?*`
+ var out strings.Builder
+ for _, char := range pkg {
+ if char < 32 || strings.ContainsRune(illegal, char) {
+ out.WriteByte('_')
+ } else {
+ out.WriteRune(char)
+ }
+ }
+ return out.String()
+}
+
+func packageName(pkg string) string {
+ if strings.HasPrefix(pkg, "@") {
+ slash := strings.IndexByte(pkg, '/')
+ if slash < 0 {
+ return pkg
+ }
+ if at := strings.IndexByte(pkg[slash:], '@'); at >= 0 {
+ return pkg[:slash+at]
+ }
+ return pkg
+ }
+ if at := strings.IndexByte(pkg, '@'); at > 0 {
+ return pkg[:at]
+ }
+ return pkg
+}
+
+func unscoped(pkg string) string {
+ if strings.HasPrefix(pkg, "@") {
+ parts := strings.Split(pkg, "/")
+ if len(parts) > 1 {
+ return parts[1]
+ }
+ }
+ return pkg
+}
+
+func resolveEntryPoint(_ string, dir string) EntryPoint {
+ manifest := filepath.Join(dir, "package.json")
+ data, err := os.ReadFile(manifest)
+ if err != nil {
+ return EntryPoint{Directory: dir}
+ }
+ var pkg struct {
+ Main string `json:"main"`
+ Module string `json:"module"`
+ }
+ if json.Unmarshal(data, &pkg) != nil {
+ return EntryPoint{Directory: dir}
+ }
+ entry := pkg.Module
+ if entry == "" {
+ entry = pkg.Main
+ }
+ if entry == "" {
+ entry = "index.js"
+ }
+ resolved := filepath.Join(dir, filepath.FromSlash(entry))
+ if _, err := os.Stat(resolved); err != nil {
+ return EntryPoint{Directory: dir}
+ }
+ return EntryPoint{Directory: dir, Entrypoint: &resolved}
+}
+
+func writable(dir string) bool {
+ info, err := os.Stat(dir)
+ if err != nil || !info.IsDir() {
+ return false
+ }
+ file, err := os.CreateTemp(dir, ".senior-dev-write-*")
+ if err != nil {
+ return false
+ }
+ name := file.Name()
+ _ = file.Close()
+ _ = os.Remove(name)
+ return true
+}
+
+func readJSONObject(path string) map[string]any {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return map[string]any{}
+ }
+ var out map[string]any
+ if json.Unmarshal(data, &out) != nil || out == nil {
+ return map[string]any{}
+ }
+ return out
+}
+
+func objectMap(value any) map[string]any {
+ out, ok := value.(map[string]any)
+ if !ok || out == nil {
+ return map[string]any{}
+ }
+ return out
+}
+
+func orderedObjectKeys(raw []byte) []string {
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ token, err := decoder.Token()
+ if err != nil || token != json.Delim('{') {
+ return nil
+ }
+ keys := []string{}
+ for decoder.More() {
+ token, err := decoder.Token()
+ if err != nil {
+ return keys
+ }
+ key, ok := token.(string)
+ if !ok {
+ return keys
+ }
+ keys = append(keys, key)
+ var discard any
+ if err := decoder.Decode(&discard); err != nil {
+ return keys
+ }
+ }
+ return keys
+}
+
+type commandReifier struct {
+ spawner *Spawner
+}
+
+func (r *commandReifier) Reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error) {
+ if err := os.MkdirAll(dir, 0o777); err != nil {
+ return nil, err
+ }
+ args := []string{"install", "--ignore-scripts", "--save", "--save-prod", "--save-prefix="}
+ args = append(args, add...)
+ _, stderr, code, err := r.spawner.Run(ctx, MakeCommand("npm", args, CommandOptions{Cwd: dir}))
+ if err != nil || code != 0 {
+ if err == nil {
+ err = errors.New(strings.TrimSpace(string(stderr)))
+ }
+ return nil, err
+ }
+ if len(add) == 0 {
+ return nil, nil
+ }
+ name := packageName(add[0])
+ return &ReifiedNode{Name: name, Path: filepath.Join(dir, "node_modules", filepath.FromSlash(name))}, nil
+}
diff --git a/internal/seniordev/core/npm_test.go b/internal/seniordev/core/npm_test.go
new file mode 100644
index 0000000000..3de92af197
--- /dev/null
+++ b/internal/seniordev/core/npm_test.go
@@ -0,0 +1,137 @@
+//go:build !windows
+
+package core
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+type reifyCall struct {
+ dir string
+ add []string
+}
+
+type mockReifier struct {
+ calls []reifyCall
+ node *ReifiedNode
+ err error
+}
+
+func (m *mockReifier) Reify(_ context.Context, dir string, add []string) (*ReifiedNode, error) {
+ m.calls = append(m.calls, reifyCall{dir: dir, add: append([]string(nil), add...)})
+ return m.node, m.err
+}
+
+func TestNpmAddExistingAndReified(t *testing.T) {
+ cache := t.TempDir()
+ reifier := &mockReifier{}
+ npm := NewNpm(cache, reifier)
+ installed := filepath.Join(cache, "packages", "@scope", "pkg@1", "node_modules", "@scope", "pkg")
+ if err := os.MkdirAll(installed, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(installed, "package.json"), []byte(`{"main":"main.js"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(installed, "main.js"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ entry, err := npm.Add(context.Background(), "@scope/pkg@1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry.Directory != installed || entry.Entrypoint == nil || len(reifier.calls) != 0 {
+ t.Fatalf("entry=%+v calls=%v", entry, reifier.calls)
+ }
+
+ reifier.node = &ReifiedNode{Name: "foo", Path: filepath.Join(cache, "installed-foo")}
+ entry, err = npm.Add(context.Background(), "foo@2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry.Directory != reifier.node.Path || !reflect.DeepEqual(reifier.calls[0].add, []string{"foo@2"}) {
+ t.Fatalf("entry=%+v calls=%v", entry, reifier.calls)
+ }
+}
+
+func TestNpmInstallChecksNodeModulesAndLock(t *testing.T) {
+ dir := t.TempDir()
+ reifier := &mockReifier{}
+ npm := NewNpm(t.TempDir(), reifier)
+ if err := npm.Install(context.Background(), dir, []PackageRequest{{Name: "a", Version: "1"}}); err != nil {
+ t.Fatal(err)
+ }
+ if len(reifier.calls) != 1 || !reflect.DeepEqual(reifier.calls[0].add, []string{"a@1"}) {
+ t.Fatalf("initial calls: %#v", reifier.calls)
+ }
+
+ reifier.calls = nil
+ if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"a":"1","b":"1"}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"packages":{"":{"dependencies":{"a":"1"}}}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := npm.Install(context.Background(), dir); err != nil {
+ t.Fatal(err)
+ }
+ if len(reifier.calls) != 1 {
+ t.Fatalf("dirty calls: %#v", reifier.calls)
+ }
+}
+
+func TestNpmWhichSelectionAndRepairFailure(t *testing.T) {
+ cache := t.TempDir()
+ reifier := &mockReifier{err: errors.New("offline")}
+ npm := NewNpm(cache, reifier)
+ dir := filepath.Join(cache, "packages", "pkg")
+ binDir := filepath.Join(dir, "node_modules", ".bin")
+ if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"other", "pkg"} {
+ if err := os.WriteFile(filepath.Join(binDir, name), nil, 0o755); err != nil {
+ if os.IsNotExist(err) {
+ if mkdirErr := os.MkdirAll(binDir, 0o755); mkdirErr != nil {
+ t.Fatal(mkdirErr)
+ }
+ if err := os.WriteFile(filepath.Join(binDir, name), nil, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "package.json"), []byte(`{"bin":{"pkg":"x","other":"y"}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, ok := npm.Which(context.Background(), "pkg")
+ if !ok || got != filepath.Join(binDir, "pkg") {
+ t.Fatalf("which = %q, %v", got, ok)
+ }
+ got, ok = npm.Which(context.Background(), "pkg", "other")
+ if !ok || got != filepath.Join(binDir, "other") {
+ t.Fatalf("which hint = %q, %v", got, ok)
+ }
+ if err := os.RemoveAll(binDir); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := npm.Which(context.Background(), "missing"); ok {
+ t.Fatal("missing package unexpectedly resolved")
+ }
+}
+
+func TestSanitizeForWindows(t *testing.T) {
+ if got := sanitizeForPlatform("a:b?c\x00d", "windows"); got != "a_b_c_d" {
+ t.Fatalf("sanitize = %q", got)
+ }
+}
diff --git a/internal/seniordev/core/spawner.go b/internal/seniordev/core/spawner.go
new file mode 100644
index 0000000000..20f1cfea34
--- /dev/null
+++ b/internal/seniordev/core/spawner.go
@@ -0,0 +1,701 @@
+//go:build !windows
+
+// Process spawner: argv/env/cwd/stdio configuration, pipelines and
+// process-group kill.
+package core
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// SystemError is a tagged spawn or I/O failure.
+type SystemError struct {
+ Tag string
+ Module string
+ Method string
+ PathOrDescriptor string
+ Syscall string
+ Cause error
+}
+
+func (e *SystemError) Error() string {
+ return fmt.Sprintf("%s.%s(%s): %v", e.Module, e.Method, e.PathOrDescriptor, e.Cause)
+}
+
+func (e *SystemError) Unwrap() error { return e.Cause }
+
+// EnvVar is one environment assignment. A slice preserves declaration order.
+type EnvVar struct {
+ Name string
+ Value string
+}
+
+// IOConfig configures a standard stream.
+type IOConfig struct {
+ Mode string // "pipe", "inherit", "ignore"
+ Reader io.Reader
+ Writer io.Writer
+}
+
+// FDConfig configures one fd >= 3.
+type FDConfig struct {
+ Type string // "input" or "output"
+ Reader io.Reader
+ Writer io.Writer
+}
+
+// CommandOptions configures a StandardCommand.
+type CommandOptions struct {
+ Cwd string
+ Env []EnvVar
+ EnvSet bool
+ ExtendEnv *bool
+
+ Stdin IOConfig
+ Stdout IOConfig
+ Stderr IOConfig
+
+ AdditionalFDs map[int]FDConfig
+ Detached *bool
+ Shell string // "", "true", or an explicit shell path
+ KillSignal os.Signal
+ ForceKillAfter time.Duration
+}
+
+// StandardCommand is one executable plus argv.
+type StandardCommand struct {
+ Command string
+ Args []string
+ Options CommandOptions
+}
+
+// PipeOptions selects the source and destination of a pipeline edge.
+type PipeOptions struct {
+ From string // stdout (default), stderr, all, fdN
+ To string // stdin (default), fdN
+}
+
+// Command is a StandardCommand or PipedCommand.
+type Command interface{ commandNode() }
+
+func (StandardCommand) commandNode() {}
+
+// PipedCommand connects Left to Right.
+type PipedCommand struct {
+ Left Command
+ Right Command
+ Options PipeOptions
+}
+
+func (PipedCommand) commandNode() {}
+
+// MakeCommand constructs a standard command.
+func MakeCommand(command string, args []string, options ...CommandOptions) StandardCommand {
+ opt := CommandOptions{}
+ if len(options) > 0 {
+ opt = options[0]
+ }
+ return StandardCommand{Command: command, Args: append([]string(nil), args...), Options: opt}
+}
+
+// Pipe constructs a piped command.
+func Pipe(left, right Command, options ...PipeOptions) PipedCommand {
+ opt := PipeOptions{}
+ if len(options) > 0 {
+ opt = options[0]
+ }
+ return PipedCommand{Left: left, Right: right, Options: opt}
+}
+
+// SpawnSpec is the fully resolved command passed to os/exec.
+type SpawnSpec struct {
+ Path string
+ Args []string
+ Cwd string
+ Env []string
+ EnvSet bool
+ Detached bool
+ Shell string
+}
+
+// BuildSpawnSpec performs the pure argv/env/cwd construction.
+func BuildSpawnSpec(command StandardCommand) (SpawnSpec, error) {
+ options := command.Options
+ cwd := ""
+ if options.Cwd != "" {
+ info, err := os.Stat(options.Cwd)
+ if err != nil {
+ return SpawnSpec{}, platformError("access", err, command)
+ }
+ if !info.IsDir() {
+ return SpawnSpec{}, platformError("access", syscall.ENOTDIR, command)
+ }
+ cwd, err = filepathAbs(options.Cwd)
+ if err != nil {
+ return SpawnSpec{}, platformError("access", err, command)
+ }
+ }
+ extend := true
+ if options.ExtendEnv != nil {
+ extend = *options.ExtendEnv
+ }
+ var environment []string
+ envSet := options.EnvSet || len(options.Env) > 0
+ if extend {
+ environment = mergeEnvironment(os.Environ(), options.Env)
+ envSet = true
+ } else if envSet {
+ environment = make([]string, 0, len(options.Env))
+ for _, item := range options.Env {
+ environment = append(environment, item.Name+"="+item.Value)
+ }
+ }
+ detached := runtime.GOOS != "windows"
+ if options.Detached != nil {
+ detached = *options.Detached
+ }
+ path := command.Command
+ args := append([]string(nil), command.Args...)
+ if options.Shell != "" {
+ shell := options.Shell
+ if shell == "true" {
+ if runtime.GOOS == "windows" {
+ shell = "cmd.exe"
+ } else {
+ shell = "/bin/sh"
+ }
+ }
+ line := strings.Join(append([]string{command.Command}, command.Args...), " ")
+ if runtime.GOOS == "windows" {
+ path, args = shell, []string{"/d", "/s", "/c", line}
+ } else {
+ path, args = shell, []string{"-c", line}
+ }
+ }
+ return SpawnSpec{
+ Path: path,
+ Args: args,
+ Cwd: cwd,
+ Env: environment,
+ EnvSet: envSet,
+ Detached: detached,
+ Shell: options.Shell,
+ }, nil
+}
+
+func filepathAbs(path string) (string, error) {
+ return filepathAbsolute(path)
+}
+
+// kept in a variable-sized helper so Windows path resolution can be tested
+// without exposing an os/exec detail.
+var filepathAbsolute = func(path string) (string, error) {
+ return filepath.Abs(path)
+}
+
+func mergeEnvironment(base []string, overrides []EnvVar) []string {
+ order := []string{}
+ values := map[string]string{}
+ for _, item := range base {
+ name, value, ok := strings.Cut(item, "=")
+ if !ok {
+ name, value = item, ""
+ }
+ if _, exists := values[name]; !exists {
+ order = append(order, name)
+ }
+ values[name] = value
+ }
+ for _, item := range overrides {
+ if _, exists := values[item.Name]; !exists {
+ order = append(order, item.Name)
+ }
+ values[item.Name] = item.Value
+ }
+ out := make([]string, 0, len(order))
+ for _, name := range order {
+ out = append(out, name+"="+values[name])
+ }
+ return out
+}
+
+// Spawner starts commands and pipelines.
+type Spawner struct{}
+
+// NewSpawner constructs the default spawner.
+func NewSpawner() *Spawner { return &Spawner{} }
+
+type flatPipeline struct {
+ commands []StandardCommand
+ options []PipeOptions
+}
+
+func flatten(command Command) (flatPipeline, error) {
+ out := flatPipeline{}
+ var walk func(Command) error
+ walk = func(command Command) error {
+ switch value := command.(type) {
+ case StandardCommand:
+ out.commands = append(out.commands, value)
+ case *StandardCommand:
+ out.commands = append(out.commands, *value)
+ case PipedCommand:
+ if err := walk(value.Left); err != nil {
+ return err
+ }
+ out.options = append(out.options, value.Options)
+ return walk(value.Right)
+ case *PipedCommand:
+ if err := walk(value.Left); err != nil {
+ return err
+ }
+ out.options = append(out.options, value.Options)
+ return walk(value.Right)
+ default:
+ return fmt.Errorf("unknown command type %T", command)
+ }
+ return nil
+ }
+ if err := walk(command); err != nil {
+ return out, err
+ }
+ if len(out.commands) == 0 {
+ return out, errors.New("flatten produced empty commands array")
+ }
+ return out, nil
+}
+
+// Handle is a running command or pipeline. Stdout/Stderr belong to the final
+// command.
+type Handle struct {
+ PID int
+ Stdin io.WriteCloser
+ Stdout io.ReadCloser
+ Stderr io.ReadCloser
+ All io.Reader
+
+ mu sync.Mutex
+ commands []*exec.Cmd
+ edges [][]io.Closer
+ done chan struct{}
+ waitErr error
+ exitCode int
+ options CommandOptions
+}
+
+// Spawn starts command and returns after every child has started.
+func (s *Spawner) Spawn(ctx context.Context, command Command) (*Handle, error) {
+ flat, err := flatten(command)
+ if err != nil {
+ return nil, err
+ }
+ commands := make([]*exec.Cmd, len(flat.commands))
+ edges := make([][]io.Closer, len(flat.commands))
+ specs := make([]SpawnSpec, len(flat.commands))
+ for i, standard := range flat.commands {
+ spec, err := BuildSpawnSpec(standard)
+ if err != nil {
+ return nil, err
+ }
+ specs[i] = spec
+ cmd := exec.Command(spec.Path, spec.Args...)
+ cmd.Dir = spec.Cwd
+ if spec.EnvSet {
+ cmd.Env = spec.Env
+ }
+ if spec.Detached && runtime.GOOS != "windows" {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ }
+ commands[i] = cmd
+ }
+
+ // Wire pipeline edges before stdio defaults so edge streams win.
+ for i, option := range flat.options {
+ from := option.From
+ if from == "" {
+ from = "stdout"
+ }
+ to := option.To
+ if to == "" {
+ to = "stdin"
+ }
+ reader, writer := io.Pipe()
+ edges[i] = append(edges[i], writer)
+ switch from {
+ case "stderr":
+ commands[i].Stderr = writer
+ case "all":
+ commands[i].Stdout = writer
+ commands[i].Stderr = writer
+ default:
+ if fd, ok := parseFDName(from); ok {
+ if err := setOutputFD(commands[i], fd, writer); err != nil {
+ return nil, err
+ }
+ } else {
+ commands[i].Stdout = writer
+ }
+ }
+ if fd, ok := parseFDName(to); ok {
+ if err := setInputFD(commands[i+1], fd, reader); err != nil {
+ return nil, err
+ }
+ } else {
+ commands[i+1].Stdin = reader
+ }
+ }
+
+ var finalStdout io.ReadCloser
+ var finalStderr io.ReadCloser
+ var finalStdin io.WriteCloser
+ var parentWriteEnds []*os.File
+ for i, standard := range flat.commands {
+ cmd := commands[i]
+ for _, fd := range SortedFDs(standard.Options.AdditionalFDs) {
+ if err := setExtraFile(cmd, fd, standard.Options.AdditionalFDs[fd]); err != nil {
+ return nil, platformError("additionalFd", err, standard)
+ }
+ }
+ if cmd.Stdin == nil {
+ switch {
+ case standard.Options.Stdin.Reader != nil:
+ cmd.Stdin = standard.Options.Stdin.Reader
+ case standard.Options.Stdin.Mode == "inherit":
+ cmd.Stdin = os.Stdin
+ case standard.Options.Stdin.Mode == "ignore":
+ cmd.Stdin = strings.NewReader("")
+ default:
+ finalStdin, err = cmd.StdinPipe()
+ if err != nil {
+ return nil, platformError("stdin", err, standard)
+ }
+ }
+ }
+ if cmd.Stdout == nil {
+ switch {
+ case standard.Options.Stdout.Writer != nil:
+ cmd.Stdout = standard.Options.Stdout.Writer
+ case standard.Options.Stdout.Mode == "inherit":
+ cmd.Stdout = os.Stdout
+ case standard.Options.Stdout.Mode == "ignore":
+ cmd.Stdout = io.Discard
+ default:
+ if i == len(commands)-1 {
+ // Explicit os.Pipe, not StdoutPipe: cmd.Wait (run from the
+ // background handle.wait goroutine) closes StdoutPipe pipes,
+ // racing consumers still draining Handle.Stdout. Output must
+ // stay readable after exit, so the consumer owns the read
+ // end. The parent write-end copy is closed after Start so EOF
+ // arrives on child exit.
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ return nil, platformError("stdout", err, standard)
+ }
+ cmd.Stdout = pw
+ finalStdout = pr
+ parentWriteEnds = append(parentWriteEnds, pw)
+ } else {
+ pipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, platformError("stdout", err, standard)
+ }
+ _ = pipe
+ }
+ }
+ }
+ if cmd.Stderr == nil {
+ switch {
+ case standard.Options.Stderr.Writer != nil:
+ cmd.Stderr = standard.Options.Stderr.Writer
+ case standard.Options.Stderr.Mode == "inherit":
+ cmd.Stderr = os.Stderr
+ case standard.Options.Stderr.Mode == "ignore":
+ cmd.Stderr = io.Discard
+ default:
+ if i == len(commands)-1 {
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ return nil, platformError("stderr", err, standard)
+ }
+ cmd.Stderr = pw
+ finalStderr = pr
+ parentWriteEnds = append(parentWriteEnds, pw)
+ } else {
+ pipe, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, platformError("stderr", err, standard)
+ }
+ _ = pipe
+ }
+ }
+ }
+ }
+
+ started := 0
+ for i, cmd := range commands {
+ if err := cmd.Start(); err != nil {
+ for j := 0; j < started; j++ {
+ _ = killCommand(commands[j], syscall.SIGTERM, specs[j].Detached)
+ }
+ for _, w := range parentWriteEnds {
+ _ = w.Close()
+ }
+ return nil, platformError("spawn", err, flat.commands[i])
+ }
+ for _, file := range cmd.ExtraFiles {
+ _ = file.Close()
+ }
+ started++
+ }
+ for _, w := range parentWriteEnds {
+ _ = w.Close()
+ }
+ handle := &Handle{
+ PID: commands[len(commands)-1].Process.Pid,
+ Stdin: finalStdin,
+ Stdout: finalStdout,
+ Stderr: finalStderr,
+ commands: commands,
+ edges: edges,
+ done: make(chan struct{}),
+ options: flat.commands[len(flat.commands)-1].Options,
+ }
+ if finalStdout != nil && finalStderr != nil {
+ handle.All = &mergedReader{readers: []io.Reader{finalStdout, finalStderr}}
+ } else if finalStdout != nil {
+ handle.All = finalStdout
+ } else {
+ handle.All = finalStderr
+ }
+ go handle.wait()
+ go func() {
+ select {
+ case <-ctx.Done():
+ _ = handle.Kill()
+ case <-handle.done:
+ }
+ }()
+ return handle, nil
+}
+
+type mergedReader struct {
+ once sync.Once
+ readers []io.Reader
+ reader *io.PipeReader
+}
+
+func (m *mergedReader) Read(p []byte) (int, error) {
+ m.once.Do(func() {
+ reader, writer := io.Pipe()
+ m.reader = reader
+ var wg sync.WaitGroup
+ for _, source := range m.readers {
+ wg.Add(1)
+ go func(source io.Reader) {
+ defer wg.Done()
+ _, _ = io.Copy(writer, source)
+ }(source)
+ }
+ go func() {
+ wg.Wait()
+ _ = writer.Close()
+ }()
+ })
+ return m.reader.Read(p)
+}
+
+func (h *Handle) wait() {
+ var lastErr error
+ lastCode := 0
+ for i, command := range h.commands {
+ err := command.Wait()
+ for _, closer := range h.edges[i] {
+ _ = closer.Close()
+ }
+ if i == len(h.commands)-1 {
+ lastErr = err
+ if command.ProcessState != nil {
+ lastCode = command.ProcessState.ExitCode()
+ }
+ }
+ }
+ h.mu.Lock()
+ h.waitErr = lastErr
+ h.exitCode = lastCode
+ h.mu.Unlock()
+ close(h.done)
+}
+
+// Wait waits for the pipeline and returns the final command's exit code.
+func (h *Handle) Wait() (int, error) {
+ <-h.done
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.exitCode, h.waitErr
+}
+
+// IsRunning reports whether Wait has completed.
+func (h *Handle) IsRunning() bool {
+ select {
+ case <-h.done:
+ return false
+ default:
+ return true
+ }
+}
+
+// Kill sends the configured signal and optionally escalates to SIGKILL.
+func (h *Handle) Kill() error {
+ signal := h.options.KillSignal
+ if signal == nil {
+ signal = syscall.SIGTERM
+ }
+ for _, command := range h.commands {
+ detached := command.SysProcAttr != nil && command.SysProcAttr.Setpgid
+ if err := killCommand(command, signal, detached); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ }
+ if h.options.ForceKillAfter > 0 {
+ timer := time.NewTimer(h.options.ForceKillAfter)
+ defer timer.Stop()
+ select {
+ case <-h.done:
+ return nil
+ case <-timer.C:
+ for _, command := range h.commands {
+ detached := command.SysProcAttr != nil && command.SysProcAttr.Setpgid
+ _ = killCommand(command, syscall.SIGKILL, detached)
+ }
+ }
+ }
+ return nil
+}
+
+func killCommand(command *exec.Cmd, signal os.Signal, detached bool) error {
+ if command.Process == nil {
+ return os.ErrProcessDone
+ }
+ if detached && runtime.GOOS != "windows" {
+ if unixSignal, ok := signal.(syscall.Signal); ok {
+ return syscall.Kill(-command.Process.Pid, unixSignal)
+ }
+ }
+ return command.Process.Signal(signal)
+}
+
+func parseFDName(name string) (int, bool) {
+ if !strings.HasPrefix(name, "fd") {
+ return 0, false
+ }
+ value, err := strconv.Atoi(strings.TrimPrefix(name, "fd"))
+ return value, err == nil && value >= 3
+}
+
+func setOutputFD(command *exec.Cmd, fd int, writer io.Writer) error {
+ return setExtraFile(command, fd, FDConfig{Type: "output", Writer: writer})
+}
+
+func setInputFD(command *exec.Cmd, fd int, reader io.Reader) error {
+ return setExtraFile(command, fd, FDConfig{Type: "input", Reader: reader})
+}
+
+func setExtraFile(command *exec.Cmd, fd int, config FDConfig) error {
+ // os/exec ExtraFiles only accepts *os.File. A small pipe bridges arbitrary
+ // readers/writers while preserving fd numbering.
+ for len(command.ExtraFiles) <= fd-3 {
+ null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
+ if err != nil {
+ return err
+ }
+ command.ExtraFiles = append(command.ExtraFiles, null)
+ }
+ read, write, err := os.Pipe()
+ if err != nil {
+ return err
+ }
+ if config.Type == "input" {
+ command.ExtraFiles[fd-3] = read
+ go func() {
+ if config.Reader != nil {
+ _, _ = io.Copy(write, config.Reader)
+ }
+ _ = write.Close()
+ }()
+ } else {
+ command.ExtraFiles[fd-3] = write
+ go func() {
+ if config.Writer != nil {
+ _, _ = io.Copy(config.Writer, read)
+ } else {
+ _, _ = io.Copy(io.Discard, read)
+ }
+ _ = read.Close()
+ }()
+ }
+ return nil
+}
+
+func platformError(method string, err error, command StandardCommand) error {
+ tag := "Unknown"
+ switch {
+ case errors.Is(err, fs.ErrNotExist), errors.Is(err, exec.ErrNotFound):
+ tag = "NotFound"
+ case errors.Is(err, fs.ErrPermission):
+ tag = "PermissionDenied"
+ case errors.Is(err, fs.ErrExist):
+ tag = "AlreadyExists"
+ case errors.Is(err, syscall.EBUSY):
+ tag = "Busy"
+ case errors.Is(err, syscall.EISDIR), errors.Is(err, syscall.ENOTDIR), errors.Is(err, syscall.ELOOP):
+ tag = "BadResource"
+ }
+ return &SystemError{
+ Tag: tag,
+ Module: "ChildProcess",
+ Method: method,
+ PathOrDescriptor: strings.TrimSpace(command.Command + " " + strings.Join(command.Args, " ")),
+ Cause: err,
+ }
+}
+
+// SortedFDs returns the valid additional fd numbers (>= 3) in ascending order.
+func SortedFDs(fds map[int]FDConfig) []int {
+ out := make([]int, 0, len(fds))
+ for fd := range fds {
+ if fd >= 3 {
+ out = append(out, fd)
+ }
+ }
+ sort.Ints(out)
+ return out
+}
+
+// Run captures stdout/stderr and waits for one command.
+func (s *Spawner) Run(ctx context.Context, command StandardCommand) ([]byte, []byte, int, error) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ command.Options.Stdout = IOConfig{Writer: &stdout}
+ command.Options.Stderr = IOConfig{Writer: &stderr}
+ handle, err := s.Spawn(ctx, command)
+ if err != nil {
+ return nil, nil, -1, err
+ }
+ code, err := handle.Wait()
+ return stdout.Bytes(), stderr.Bytes(), code, err
+}
diff --git a/internal/seniordev/core/spawner_test.go b/internal/seniordev/core/spawner_test.go
new file mode 100644
index 0000000000..caa0b4200d
--- /dev/null
+++ b/internal/seniordev/core/spawner_test.go
@@ -0,0 +1,178 @@
+//go:build !windows
+
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestCoreHelperProcess(t *testing.T) {
+ if os.Getenv("GO_CORE_HELPER") != "1" {
+ return
+ }
+ separator := 0
+ for i, arg := range os.Args {
+ if arg == "--" {
+ separator = i + 1
+ break
+ }
+ }
+ cwd, _ := os.Getwd()
+ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+ "args": os.Args[separator:],
+ "cwd": cwd,
+ "env": os.Getenv("CORE_VALUE"),
+ })
+ os.Exit(0)
+}
+
+func TestSpawnerArgvEnvAndCwd(t *testing.T) {
+ root := t.TempDir()
+ spawner := NewSpawner()
+ command := MakeCommand(os.Args[0], []string{
+ "-test.run=TestCoreHelperProcess", "--", "space arg", "", "🙂",
+ }, CommandOptions{
+ Cwd: root,
+ Env: []EnvVar{
+ {Name: "GO_CORE_HELPER", Value: "1"},
+ {Name: "CORE_VALUE", Value: "value"},
+ },
+ })
+ stdout, stderr, code, err := spawner.Run(context.Background(), command)
+ if err != nil || code != 0 {
+ t.Fatalf("run code=%d err=%v stderr=%s", code, err, stderr)
+ }
+ var got struct {
+ Args []string `json:"args"`
+ Cwd string `json:"cwd"`
+ Env string `json:"env"`
+ }
+ if err := json.Unmarshal(stdout, &got); err != nil {
+ t.Fatalf("decode %q: %v", stdout, err)
+ }
+ if !reflect.DeepEqual(got.Args, []string{"space arg", "", "🙂"}) {
+ t.Fatalf("args: %#v", got.Args)
+ }
+ // The child reports its folder as the kernel resolves it, and a temporary
+ // folder on macOS is a symlink (/var/folders → /private/var/folders), so
+ // the two are compared resolved: the same folder spelled two ways is the
+ // same folder.
+ if resolvedPath(t, got.Cwd) != resolvedPath(t, root) || got.Env != "value" {
+ t.Fatalf("helper: %+v", got)
+ }
+}
+
+func resolvedPath(t *testing.T, path string) string {
+ t.Helper()
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ t.Fatalf("resolve %s: %v", path, err)
+ }
+ return resolved
+}
+
+func TestSpawnerPipeline(t *testing.T) {
+ if _, err := exec.LookPath("printf"); err != nil {
+ t.Skip("printf unavailable")
+ }
+ if _, err := exec.LookPath("tr"); err != nil {
+ t.Skip("tr unavailable")
+ }
+ spawner := NewSpawner()
+ handle, err := spawner.Spawn(context.Background(), Pipe(
+ MakeCommand("printf", []string{"alpha\nbeta\n"}),
+ MakeCommand("tr", []string{"a-z", "A-Z"}),
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+ stdout, err := io.ReadAll(handle.Stdout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stderr, err := io.ReadAll(handle.Stderr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, err := handle.Wait()
+ if err != nil || code != 0 {
+ t.Fatalf("wait code=%d err=%v stderr=%s", code, err, stderr)
+ }
+ if string(stdout) != "ALPHA\nBETA\n" {
+ t.Fatalf("stdout: %q", stdout)
+ }
+}
+
+func TestSpawnerMissingCommandIsTagged(t *testing.T) {
+ _, err := NewSpawner().Spawn(context.Background(), MakeCommand("definitely-no-senior-dev-command", nil))
+ var system *SystemError
+ if !errors.As(err, &system) {
+ t.Fatalf("error = %v", err)
+ }
+ if system.Tag != "NotFound" || system.Method != "spawn" {
+ t.Fatalf("system error: %+v", system)
+ }
+}
+
+func TestSpawnerContextCancellationKillsProcess(t *testing.T) {
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh unavailable")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ handle, err := NewSpawner().Spawn(ctx, MakeCommand("sh", []string{"-c", "sleep 30"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ done := make(chan struct{})
+ go func() {
+ _, _ = handle.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("process did not stop")
+ }
+ if handle.IsRunning() {
+ t.Fatal("handle still running")
+ }
+}
+
+func TestBuildSpawnSpecEnvironmentModes(t *testing.T) {
+ no := false
+ spec, err := BuildSpawnSpec(MakeCommand("x", nil, CommandOptions{
+ ExtendEnv: &no,
+ EnvSet: true,
+ Env: []EnvVar{{Name: "A", Value: "1"}, {Name: "B", Value: "2"}},
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !spec.EnvSet || !reflect.DeepEqual(spec.Env, []string{"A=1", "B=2"}) {
+ t.Fatalf("env: %#v", spec)
+ }
+ spec, err = BuildSpawnSpec(MakeCommand("echo", []string{"$HOME"}, CommandOptions{Shell: "true"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Base(spec.Path) != "sh" || !reflect.DeepEqual(spec.Args, []string{"-c", "echo $HOME"}) {
+ t.Fatalf("shell spec: %#v", spec)
+ }
+}
+
+func TestSortedFDs(t *testing.T) {
+ got := SortedFDs(map[int]FDConfig{9: {}, 2: {}, 3: {}, 5: {}})
+ if !reflect.DeepEqual(got, []int{3, 5, 9}) {
+ t.Fatalf("fds: %v", got)
+ }
+}
diff --git a/internal/seniordev/crew_test.go b/internal/seniordev/crew_test.go
new file mode 100644
index 0000000000..2b26d0fb92
--- /dev/null
+++ b/internal/seniordev/crew_test.go
@@ -0,0 +1,33 @@
+//go:build !windows
+
+package seniordev
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/delegate"
+)
+
+// The crew reaches senior-dev as its own flags: the working seat is the pool
+// it routes on, the light seat its summaries, and a seat left unset keeps
+// senior-dev's own default. The planning seat is not passed: no call senior-dev
+// makes rides the tier it would set.
+func TestTheCrewBecomesSeniorDevsOwnPools(t *testing.T) {
+ got := strings.Join(crewFlags(delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}), " ")
+ if want := "--crew --high openrouter/vendor/hands --low openrouter/vendor/light"; got != want {
+ t.Fatalf("flags = %q, want %q", got, want)
+ }
+ if got := strings.Join(crewFlags(delegate.Crew{Hands: "vendor/hands"}), " "); got != "--crew --high openrouter/vendor/hands" {
+ t.Fatalf("flags for a crew with one seat = %q", got)
+ }
+}
+
+// Models the person asked for are the working pool in place of the crew's
+// working seat, kept as asked (`--asked`), and the light seat still summarises.
+func TestTheModelsAPersonAskedForAreSeniorDevsWorkingPool(t *testing.T) {
+ got := strings.Join(crewFlags(delegate.Crew{Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}}), " ")
+ if want := "--crew --asked --high openrouter/vendor/one,openrouter/vendor/two --low openrouter/vendor/light"; got != want {
+ t.Fatalf("flags = %q, want %q", got, want)
+ }
+}
diff --git a/internal/seniordev/engine/calc/calc.go b/internal/seniordev/engine/calc/calc.go
new file mode 100644
index 0000000000..843df4a66b
--- /dev/null
+++ b/internal/seniordev/engine/calc/calc.go
@@ -0,0 +1,69 @@
+//go:build !windows
+
+// Package calc holds the pure token and cost arithmetic that runs between an
+// OpenRouter response and a persisted assistant message: it normalises the
+// provider usage block, prices a call from the model catalog, and derives the
+// compaction budget and its watermarks from the model limits and the
+// compaction config.
+package calc
+
+import (
+ "math"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// ── process-start constants ──────────────────────────────────────────────
+
+// processEnv is the process environment as a map. Split on the first '=' so
+// a value containing '=' survives.
+func processEnv() map[string]string {
+ out := make(map[string]string)
+ for _, kv := range os.Environ() {
+ if i := strings.IndexByte(kv, '='); i >= 0 {
+ out[kv[:i]] = kv[i+1:]
+ }
+ }
+ return out
+}
+
+// OUTPUT_TOKEN_MAX_DEFAULT is the output-token ceiling when
+// SENIOR_DEV_OUTPUT_TOKEN_MAX is unset.
+const OUTPUT_TOKEN_MAX_DEFAULT float64 = 32_000
+
+// outputTokenMax is the output-token ceiling every request is capped at:
+// SENIOR_DEV_OUTPUT_TOKEN_MAX, or OUTPUT_TOKEN_MAX_DEFAULT. It is evaluated ONCE
+// at package init; a runtime change to the variable does not move it.
+var outputTokenMax = evalOutputTokenMax(processEnv())
+
+// evalOutputTokenMax reads SENIOR_DEV_OUTPUT_TOKEN_MAX: a positive integer
+// (decimal or exponent notation) is the ceiling; absent, empty, "0" or
+// anything else falls back to the default.
+func evalOutputTokenMax(env map[string]string) float64 {
+ raw := env["SENIOR_DEV_OUTPUT_TOKEN_MAX"]
+ if raw == "" || raw == "0" {
+ return OUTPUT_TOKEN_MAX_DEFAULT
+ }
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+ if err == nil && parsed > 0 && math.Trunc(parsed) == parsed && !math.IsInf(parsed, 0) {
+ return parsed
+ }
+ return OUTPUT_TOKEN_MAX_DEFAULT
+}
+
+// SetModuleEnvForTesting re-runs the package-init evaluation of
+// OUTPUT_TOKEN_MAX against the supplied environment. Returns a restore func.
+func SetModuleEnvForTesting(env map[string]string) func() {
+ previous := outputTokenMax
+ outputTokenMax = evalOutputTokenMax(env)
+ return func() { outputTokenMax = previous }
+}
+
+// safe maps a non-finite value to 0.
+func safe(value float64) float64 {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0
+ }
+ return value
+}
diff --git a/internal/seniordev/engine/calc/cost_test.go b/internal/seniordev/engine/calc/cost_test.go
new file mode 100644
index 0000000000..561c745861
--- /dev/null
+++ b/internal/seniordev/engine/calc/cost_test.go
@@ -0,0 +1,100 @@
+//go:build !windows
+
+package calc
+
+import (
+ "encoding/json"
+ "math"
+ "testing"
+)
+
+func costTokens(input, output, reasoning, cacheRead, cacheWrite float64) UsageTokens {
+ return UsageTokens{
+ Input: input,
+ Output: output,
+ Reasoning: reasoning,
+ Cache: UsageCache{Write: cacheWrite, Read: cacheRead},
+ }
+}
+
+func TestCostPricesEveryTokenClass(t *testing.T) {
+ cases := []struct {
+ name string
+ toks UsageTokens
+ rates costRates
+ want float64
+ }{
+ {
+ name: "sonnet-shaped run",
+ toks: costTokens(3_590_000, 250_000, 100_000, 2_000_000, 500_000),
+ rates: costRates{input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75},
+ want: 18.495,
+ },
+ {
+ name: "reasoning is billed at the output rate",
+ toks: costTokens(0, 0, 1_000_000, 0, 0),
+ rates: costRates{input: 1, output: 4},
+ want: 4,
+ },
+ {
+ name: "no rates means free",
+ toks: costTokens(10, 10, 10, 10, 10),
+ rates: costRates{},
+ want: 0,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := cost(tc.toks, tc.rates); math.Abs(got-tc.want) > 1e-9 {
+ t.Errorf("cost = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestGetUsageSubtractsCacheTokensAndAppliesRates(t *testing.T) {
+ f := func(v float64) *float64 { return &v }
+ result := GetUsage(GetUsageInput{
+ Model: Model{Cost: &ModelCost{Input: 1, Output: 2, Cache: &CacheCost{Read: 0.1, Write: 1.25}}},
+ Usage: LanguageModelUsage{
+ InputTokens: f(1_000_000),
+ InputTokenDetails: &InputTokenDetails{CacheReadTokens: f(400_000), CacheWriteTokens: f(100_000)},
+ OutputTokens: f(200_000),
+ OutputTokenDetails: &OutputTokenDetails{ReasoningTokens: f(50_000)},
+ TotalTokens: f(1_200_000),
+ },
+ })
+ if result.Tokens.Input != 500_000 || result.Tokens.Output != 150_000 || result.Tokens.Reasoning != 50_000 {
+ t.Fatalf("tokens = %+v", result.Tokens)
+ }
+ if result.Tokens.Cache.Read != 400_000 || result.Tokens.Cache.Write != 100_000 {
+ t.Fatalf("cache = %+v", result.Tokens.Cache)
+ }
+ // 0.5 + 0.3 + 0.1 + 0.04 + 0.125 = 1.065
+ if math.Abs(result.Cost-1.065) > 1e-9 {
+ t.Fatalf("cost = %v", result.Cost)
+ }
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{"cost":1.065,"tokens":{"total":1200000,"input":500000,"output":150000,"reasoning":50000,"cache":{"write":100000,"read":400000}}}`
+ if string(encoded) != want {
+ t.Fatalf("json:\n got %s\nwant %s", encoded, want)
+ }
+}
+
+func TestGetUsageReadsProviderMetadataCacheWrites(t *testing.T) {
+ f := func(v float64) *float64 { return &v }
+ result := GetUsage(GetUsageInput{
+ Model: Model{Cost: &ModelCost{Input: 1, Cache: &CacheCost{Write: 2}}},
+ Usage: LanguageModelUsage{InputTokens: f(300)},
+ Metadata: ProviderMetadata{"anthropic": {"cacheCreationInputTokens": float64(100)}},
+ })
+ if result.Tokens.Input != 200 || result.Tokens.Cache.Write != 100 {
+ t.Fatalf("tokens = %+v", result.Tokens)
+ }
+ if got := safe(math.NaN()); got != 0 {
+ t.Fatalf("safe(NaN) = %v", got)
+ }
+}
diff --git a/internal/seniordev/engine/calc/overflow.go b/internal/seniordev/engine/calc/overflow.go
new file mode 100644
index 0000000000..3e77184643
--- /dev/null
+++ b/internal/seniordev/engine/calc/overflow.go
@@ -0,0 +1,250 @@
+//go:build !windows
+
+package calc
+
+import (
+ "fmt"
+ "math"
+)
+
+// ── the slice of config / model the compaction budget reads ──────────────
+
+// CompactionConfig is the `compaction` block of project config. Every field
+// is optional, so every field is a pointer: `auto` is tested strictly (an
+// absent value is NOT false) and `reserved` nullishly (an explicit 0 wins).
+type CompactionConfig struct {
+ // Policy names how the compaction budget is derived. The only policy is
+ // "window": the budget is the model's own context window, capped by
+ // CapacityTokens. It may be spelled out or left empty; any other name is
+ // refused by ValidatePolicy.
+ Policy string `json:"policy,omitempty"`
+ Auto *bool `json:"auto"`
+ Prune *bool `json:"prune"`
+ // PreserveRecentTokens overrides the verbatim tail budget a compaction
+ // keeps ahead of the summary. The tail is sized in tokens after
+ // truncation, never in turns.
+ PreserveRecentTokens *float64 `json:"preserve_recent_tokens"`
+ // PreserveRecentFraction sizes the verbatim tail as a fraction of the
+ // high watermark instead of a fixed token count, so it scales with the
+ // window. PreserveRecentTokens wins when both are set.
+ PreserveRecentFraction *float64 `json:"preserve_recent_fraction"`
+ // CapacityTokens caps the working set below the model's window: a cost
+ // decision, or a model known to degrade before its advertised context.
+ // Absent means DefaultCapacityTokens.
+ CapacityTokens *float64 `json:"capacity_tokens"`
+ Reserved *float64 `json:"reserved"`
+}
+
+// PolicyWindow is the compaction policy: the window is the budget.
+const PolicyWindow = "window"
+
+// DefaultCapacityTokens caps the working set when no capacity_tokens is
+// configured. A model with a smaller window is bounded by the window.
+const DefaultCapacityTokens float64 = 500_000
+
+// ValidatePolicy refuses a policy name this binary does not implement, and a
+// budget field outside its range. Both are refused at config load so a
+// misspelled block fails before any model call.
+func ValidatePolicy(cfg Config) error {
+ if cfg.Compaction == nil {
+ return nil
+ }
+ switch cfg.Compaction.Policy {
+ case "", PolicyWindow:
+ default:
+ return fmt.Errorf("compaction.policy %q is not %q", cfg.Compaction.Policy, PolicyWindow)
+ }
+ if f := cfg.Compaction.PreserveRecentFraction; f != nil && (math.IsNaN(*f) || *f <= 0 || *f >= 1) {
+ return fmt.Errorf("compaction.preserve_recent_fraction %v must be between 0 and 1 exclusive", *f)
+ }
+ if c := cfg.Compaction.CapacityTokens; c != nil && (math.IsNaN(*c) || math.IsInf(*c, 0) || *c <= 0) {
+ return fmt.Errorf("compaction.capacity_tokens %v must be a positive token count", *c)
+ }
+ return nil
+}
+
+// Config is the project-config projection this package needs: only the
+// `compaction` block is read.
+type Config struct {
+ Compaction *CompactionConfig `json:"compaction"`
+}
+
+// ModelLimit is a model's context, input and output limits. Input is
+// optional: a catalog entry that names none is budgeted from Context.
+type ModelLimit struct {
+ Context float64 `json:"context"`
+ Input *float64 `json:"input"`
+ Output float64 `json:"output"`
+}
+
+// CacheCost is the per-token price of prompt-cache reads and writes.
+type CacheCost struct {
+ Read float64 `json:"read"`
+ Write float64 `json:"write"`
+}
+
+// Over200KCost is the price block a provider applies above 200K context.
+// The key order is cache, input, output.
+type Over200KCost struct {
+ Cache *CacheCost `json:"cache"`
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+}
+
+// ModelCost is a model's price block. `cache` is optional in practice, so it
+// is a pointer.
+type ModelCost struct {
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+ Cache *CacheCost `json:"cache"`
+ ExperimentalOver200K *Over200KCost `json:"experimentalOver200K"`
+}
+
+// Model is the catalog projection this package needs: the limit block (for
+// the budget and the output reservation) and the cost block (for usage).
+type Model struct {
+ Cost *ModelCost `json:"cost"`
+ Limit ModelLimit `json:"limit"`
+ Capabilities ModelCapabilities `json:"-"`
+}
+
+// ModelCapabilities is the models.dev capability slice retained alongside
+// cost and limits so provider request assembly does not invent support.
+type ModelCapabilities struct {
+ Attachment bool `json:"attachment"`
+ Reasoning bool `json:"reasoning"`
+ Temperature bool `json:"temperature"`
+ ToolCall bool `json:"toolcall"`
+ Input map[string]bool `json:"input"`
+ Output map[string]bool `json:"output"`
+}
+
+// ── budget constants ─────────────────────────────────────────────────────
+
+// COMPACTION_BUFFER bounds the output reservation taken off the window.
+const COMPACTION_BUFFER float64 = 20_000
+
+// TRIGGER_PCT is the fraction of the capacity at which auto-compaction
+// fires: the high watermark.
+const TRIGGER_PCT float64 = 0.6
+
+// COMPACTION_LOW_TO_HIGH_RATIO is the low-watermark half of the 40/60
+// hysteresis: low is two-thirds of high.
+const COMPACTION_LOW_TO_HIGH_RATIO float64 = 2.0 / 3.0
+
+// MaxOutputTokens is `min(model.limit.output, OUTPUT_TOKEN_MAX)`, falling
+// back to OUTPUT_TOKEN_MAX when the minimum is 0 or NaN. A negative
+// limit.output is passed through as is.
+func MaxOutputTokens(model Model) float64 {
+ minimum := math.Min(model.Limit.Output, outputTokenMax)
+ if minimum != 0 && !math.IsNaN(minimum) {
+ return minimum
+ }
+ return outputTokenMax
+}
+
+// UsableInput is what the budget is derived from: the compaction config and
+// the model's limits.
+type UsableInput struct {
+ Cfg Config
+ Model Model
+}
+
+// EffectiveInputCapacity is the model-visible input capacity after reserving
+// output space and applying the capacity cap. It deliberately does not apply
+// the trigger percentage; Watermarks derives both marks from this one
+// underlying capacity.
+func EffectiveInputCapacity(input UsableInput) float64 {
+ context := input.Model.Limit.Context
+ if context == 0 {
+ return 0
+ }
+
+ reserved := math.Min(COMPACTION_BUFFER, MaxOutputTokens(input.Model))
+ if input.Cfg.Compaction != nil && input.Cfg.Compaction.Reserved != nil {
+ reserved = *input.Cfg.Compaction.Reserved
+ }
+
+ var raw float64
+ // An input limit of 0 (or NaN, or absent) takes the context branch.
+ if input.Model.Limit.Input != nil && *input.Model.Limit.Input != 0 && !math.IsNaN(*input.Model.Limit.Input) {
+ raw = math.Max(0, *input.Model.Limit.Input-reserved)
+ } else {
+ raw = math.Max(0, context-MaxOutputTokens(input.Model))
+ }
+ capacity := DefaultCapacityTokens
+ if input.Cfg.Compaction != nil && input.Cfg.Compaction.CapacityTokens != nil &&
+ *input.Cfg.Compaction.CapacityTokens > 0 {
+ capacity = *input.Cfg.Compaction.CapacityTokens
+ }
+ return math.Min(raw, capacity)
+}
+
+// CompactionWatermarks describes the preferred post-compaction target and the
+// occupancy at which another compaction becomes necessary.
+type CompactionWatermarks struct {
+ Capacity float64
+ Low float64
+ High float64
+}
+
+// Watermarks returns the 40/60 hysteresis around the capacity.
+func Watermarks(input UsableInput) CompactionWatermarks {
+ capacity := EffectiveInputCapacity(input)
+ high := math.Floor(capacity * TRIGGER_PCT)
+ low := math.Floor(high * COMPACTION_LOW_TO_HIGH_RATIO)
+ return CompactionWatermarks{Capacity: capacity, Low: low, High: high}
+}
+
+// ── the token counter the trigger scores ─────────────────────────────────
+
+// TokenCache is the persisted cache-token pair, declared read then write.
+// Contrast UsageCache, which is the same data in the order usage builds it.
+type TokenCache struct {
+ Read float64 `json:"read"`
+ Write float64 `json:"write"`
+}
+
+// Tokens is the persisted assistant token block. `total` is optional, so it
+// is a pointer and the key is dropped when it is absent.
+type Tokens struct {
+ Total *float64 `json:"total,omitempty"`
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+ Reasoning float64 `json:"reasoning"`
+ Cache TokenCache `json:"cache"`
+}
+
+// tokenCount is `tokens.total || input + output + cache.read + cache.write`:
+// a total of 0 or NaN falls through to the sum.
+func tokenCount(tokens Tokens) float64 {
+ if tokens.Total != nil && *tokens.Total != 0 && !math.IsNaN(*tokens.Total) {
+ return *tokens.Total
+ }
+ return tokens.Input + tokens.Output + tokens.Cache.Read + tokens.Cache.Write
+}
+
+// autoDisabled is `compaction.auto === false` -- a STRICT comparison, so an
+// absent block or an absent `auto` does not disable compaction.
+func autoDisabled(cfg Config) bool {
+ return cfg.Compaction != nil && cfg.Compaction.Auto != nil && !*cfg.Compaction.Auto
+}
+
+// OverflowInput is what the trigger decides on.
+type OverflowInput struct {
+ Cfg Config
+ Tokens Tokens
+ Model Model
+}
+
+// IsOverflow reports whether the assistant's token count has reached the
+// high watermark.
+func IsOverflow(input OverflowInput) bool {
+ if autoDisabled(input.Cfg) {
+ return false
+ }
+ if input.Model.Limit.Context == 0 {
+ return false
+ }
+ return tokenCount(input.Tokens) >= Watermarks(UsableInput{Cfg: input.Cfg, Model: input.Model}).High
+}
diff --git a/internal/seniordev/engine/calc/overflow_test.go b/internal/seniordev/engine/calc/overflow_test.go
new file mode 100644
index 0000000000..d82eb76ffe
--- /dev/null
+++ b/internal/seniordev/engine/calc/overflow_test.go
@@ -0,0 +1,205 @@
+//go:build !windows
+
+package calc
+
+import (
+ "math"
+ "testing"
+)
+
+func cfgEmpty() Config { return Config{Compaction: &CompactionConfig{}} }
+
+func cfgWith(mutate func(*CompactionConfig)) Config {
+ c := &CompactionConfig{}
+ mutate(c)
+ return Config{Compaction: c}
+}
+
+func testModel(context float64, input *float64, output float64) Model {
+ return Model{Limit: ModelLimit{Context: context, Input: input, Output: output}}
+}
+
+func ptr[T any](v T) *T { return &v }
+
+func totalTokens(total float64) Tokens {
+ return Tokens{Total: &total, Input: 0, Output: 0, Cache: TokenCache{Read: 0, Write: 0}}
+}
+
+// ── capacity ─────────────────────────────────────────────────────────────
+
+func TestEffectiveInputCapacity(t *testing.T) {
+ t.Run("a small window is the budget", func(t *testing.T) {
+ // 128K input limit - 8,192 output reserve, under the default cap.
+ got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(131_072, ptr(128_000.0), 8_192)})
+ if got != 128_000-8_192 {
+ t.Errorf("capacity = %v, want %v", got, 128_000-8_192)
+ }
+ })
+
+ t.Run("a large window is capped at the default capacity", func(t *testing.T) {
+ got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(1_310_720, nil, 943_718)})
+ if got != DefaultCapacityTokens {
+ t.Errorf("capacity = %v, want the %v default", got, DefaultCapacityTokens)
+ }
+ if absent := EffectiveInputCapacity(UsableInput{Model: testModel(1_310_720, nil, 943_718)}); absent != DefaultCapacityTokens {
+ t.Errorf("capacity with no compaction block = %v, want the default", absent)
+ }
+ })
+
+ t.Run("an absent input limit is budgeted from the context minus the output cap", func(t *testing.T) {
+ // OUTPUT_TOKEN_MAX (32,000) is the reservation when limit.output exceeds it.
+ got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(400_000, nil, 384_000)})
+ if got != 400_000-32_000 {
+ t.Errorf("capacity = %v, want %v", got, 400_000-32_000)
+ }
+ })
+
+ t.Run("capacity_tokens tightens and never widens", func(t *testing.T) {
+ model := testModel(1_310_720, nil, 943_718)
+ tight := EffectiveInputCapacity(UsableInput{Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(100_000.0) }), Model: model})
+ if tight != 100_000 {
+ t.Errorf("tightened capacity = %v, want 100000", tight)
+ }
+ wide := EffectiveInputCapacity(UsableInput{Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(5_000_000.0) }), Model: model})
+ if wide != 1_310_720-32_000 {
+ t.Errorf("a cap above the window must not widen it: %v", wide)
+ }
+ })
+
+ t.Run("reserved overrides the output reservation", func(t *testing.T) {
+ got := EffectiveInputCapacity(UsableInput{
+ Cfg: cfgWith(func(c *CompactionConfig) { c.Reserved = ptr(131_072.0) }),
+ Model: testModel(400_000, ptr(400_000.0), 943_718),
+ })
+ if got != 400_000-131_072 {
+ t.Errorf("capacity = %v, want %v", got, 400_000-131_072)
+ }
+ })
+
+ t.Run("a zero context has no capacity", func(t *testing.T) {
+ if got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(0, nil, 0)}); got != 0 {
+ t.Errorf("capacity = %v, want 0", got)
+ }
+ })
+}
+
+func TestWatermarksAreASixtyFortySplitOfTheCapacity(t *testing.T) {
+ marks := Watermarks(UsableInput{
+ Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(500_000.0) }),
+ Model: testModel(1_310_720, nil, 943_718),
+ })
+ if marks.Capacity != 500_000 || marks.High != 300_000 || marks.Low != 200_000 {
+ t.Fatalf("watermarks = %#v, want 500000/300000/200000", marks)
+ }
+ small := Watermarks(UsableInput{Cfg: cfgEmpty(), Model: testModel(100_000, ptr(100_000.0), 10_000)})
+ if small.Capacity != 90_000 || small.High != 54_000 || small.Low != 36_000 {
+ t.Fatalf("small watermarks = %#v, want 90000/54000/36000", small)
+ }
+}
+
+// ── the trigger ──────────────────────────────────────────────────────────
+
+func TestIsOverflowTriggersOnOccupancyOnly(t *testing.T) {
+ cfg := cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(500_000.0) })
+ model := testModel(1_310_720, nil, 943_718)
+ if IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: totalTokens(299_999)}) {
+ t.Error("should not fire below high")
+ }
+ if !IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: totalTokens(300_000)}) {
+ t.Error("should fire at high")
+ }
+ // A total of 0 falls through to the component sum.
+ summed := Tokens{Input: 200_000, Output: 50_000, Cache: TokenCache{Read: 50_000}}
+ if !IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: summed}) {
+ t.Error("the component sum should trigger when total is absent")
+ }
+ // auto:false disables everything.
+ off := cfgWith(func(c *CompactionConfig) { c.Auto = ptr(false) })
+ if IsOverflow(OverflowInput{Cfg: off, Model: model, Tokens: totalTokens(9_000_000)}) {
+ t.Error("auto=false must win")
+ }
+ // A model with no context never overflows.
+ if IsOverflow(OverflowInput{Cfg: cfg, Model: testModel(0, nil, 0), Tokens: totalTokens(9_000_000)}) {
+ t.Error("a zero-context model must not overflow")
+ }
+}
+
+// ── the output cap ───────────────────────────────────────────────────────
+
+// OUTPUT_TOKEN_MAX is read once at package init; only SetModuleEnvForTesting
+// moves it.
+func TestOutputTokenMaxIsReadAtInit(t *testing.T) {
+ if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 32_000 {
+ t.Errorf("default OUTPUT_TOKEN_MAX: got %v, want 32000", got)
+ }
+ restore := SetModuleEnvForTesting(map[string]string{"SENIOR_DEV_OUTPUT_TOKEN_MAX": "1000"})
+ if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 1_000 {
+ t.Errorf("module env did not move OUTPUT_TOKEN_MAX: got %v, want 1000", got)
+ }
+ restore()
+ if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 32_000 {
+ t.Errorf("restore leaked: got %v, want 32000", got)
+ }
+ // A model whose own output limit is below the cap keeps its limit.
+ if got := MaxOutputTokens(testModel(200_000, nil, 12_000)); got != 12_000 {
+ t.Errorf("model limit below the cap: got %v, want 12000", got)
+ }
+}
+
+// Only a positive integer moves the cap; the falsy strings and malformed
+// values fall back to the default.
+func TestEvalOutputTokenMax(t *testing.T) {
+ for _, tc := range []struct {
+ raw string
+ want float64
+ }{
+ {"", 32_000},
+ {"0", 32_000},
+ {"-4", 32_000},
+ {"1.5", 32_000},
+ {"banana", 32_000},
+ {"8000", 8_000},
+ {"1", 1},
+ {"1e4", 10_000},
+ {"0x20", 32_000},
+ {"131072", 131_072},
+ } {
+ got := evalOutputTokenMax(map[string]string{"SENIOR_DEV_OUTPUT_TOKEN_MAX": tc.raw})
+ if got != tc.want {
+ t.Errorf("evalOutputTokenMax(%q) = %v, want %v", tc.raw, got, tc.want)
+ }
+ }
+ if got := evalOutputTokenMax(map[string]string{}); got != 32_000 {
+ t.Errorf("evalOutputTokenMax(absent) = %v, want 32000", got)
+ }
+}
+
+// ── validation ───────────────────────────────────────────────────────────
+
+func TestPolicyValidation(t *testing.T) {
+ for _, name := range []string{"", PolicyWindow} {
+ if err := ValidatePolicy(Config{Compaction: &CompactionConfig{Policy: name}}); err != nil {
+ t.Errorf("policy %q should validate: %v", name, err)
+ }
+ }
+ if err := ValidatePolicy(Config{}); err != nil {
+ t.Errorf("absent block should validate: %v", err)
+ }
+ for _, name := range []string{"legacy", "adaptive"} {
+ if err := ValidatePolicy(Config{Compaction: &CompactionConfig{Policy: name}}); err == nil {
+ t.Errorf("policy %q must be refused, not ignored", name)
+ }
+ }
+ for _, bad := range []float64{0, 1, 1.5, -0.2, math.NaN()} {
+ f := bad
+ if err := ValidatePolicy(cfgWith(func(c *CompactionConfig) { c.PreserveRecentFraction = &f })); err == nil {
+ t.Errorf("preserve_recent_fraction %v must be refused", bad)
+ }
+ }
+ for _, bad := range []float64{0, -1, math.Inf(1), math.NaN()} {
+ c := bad
+ if err := ValidatePolicy(cfgWith(func(cfg *CompactionConfig) { cfg.CapacityTokens = &c })); err == nil {
+ t.Errorf("capacity_tokens %v must be refused", bad)
+ }
+ }
+}
diff --git a/internal/seniordev/engine/calc/usage.go b/internal/seniordev/engine/calc/usage.go
new file mode 100644
index 0000000000..8097a67161
--- /dev/null
+++ b/internal/seniordev/engine/calc/usage.go
@@ -0,0 +1,400 @@
+//go:build !windows
+
+package calc
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// ── stage 1: the provider's usage block ──────────────────────────────────
+
+// OpenRouterPromptTokensDetails is `usage.prompt_tokens_details`.
+type OpenRouterPromptTokensDetails struct {
+ CachedTokens *float64 `json:"cached_tokens"`
+ CacheWriteTokens *float64 `json:"cache_write_tokens"`
+}
+
+// OpenRouterCompletionTokensDetails is `usage.completion_tokens_details`.
+type OpenRouterCompletionTokensDetails struct {
+ ReasoningTokens *float64 `json:"reasoning_tokens"`
+}
+
+// OpenRouterUsage is the numeric projection of OpenRouter's `usage` object
+// that the token arithmetic reads, plus the untouched original carried
+// through as Raw.
+type OpenRouterUsage struct {
+ PromptTokens *float64 `json:"prompt_tokens"`
+ CompletionTokens *float64 `json:"completion_tokens"`
+ PromptTokensDetails *OpenRouterPromptTokensDetails `json:"prompt_tokens_details"`
+ CompletionTokensDetails *OpenRouterCompletionTokensDetails `json:"completion_tokens_details"`
+
+ // Raw is the untouched wire object, including fields such as `cost` and
+ // `is_byok` that senior-dev never reads.
+ Raw json.RawMessage `json:"-"`
+}
+
+// UnmarshalJSON decodes the numeric projection and keeps the original bytes.
+func (u *OpenRouterUsage) UnmarshalJSON(data []byte) error {
+ type shadow OpenRouterUsage
+ var decoded shadow
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return err
+ }
+ *u = OpenRouterUsage(decoded)
+ u.Raw = append(json.RawMessage(nil), data...)
+ return nil
+}
+
+// LanguageModelV3InputTokens is the provider-side `inputTokens` block.
+type LanguageModelV3InputTokens struct {
+ Total *float64 `json:"total,omitempty"`
+ NoCache *float64 `json:"noCache,omitempty"`
+ CacheRead *float64 `json:"cacheRead,omitempty"`
+ CacheWrite *float64 `json:"cacheWrite,omitempty"`
+}
+
+// LanguageModelV3OutputTokens is the provider-side `outputTokens` block.
+type LanguageModelV3OutputTokens struct {
+ Total *float64 `json:"total,omitempty"`
+ Text *float64 `json:"text,omitempty"`
+ Reasoning *float64 `json:"reasoning,omitempty"`
+}
+
+// LanguageModelV3Usage is ComputeTokenUsage's return shape.
+type LanguageModelV3Usage struct {
+ InputTokens LanguageModelV3InputTokens `json:"inputTokens"`
+ OutputTokens LanguageModelV3OutputTokens `json:"outputTokens"`
+ Raw json.RawMessage `json:"raw,omitempty"`
+}
+
+// ComputeTokenUsage splits the provider's usage block into input and output
+// token groups. An absent cache-write count stays absent (nil) so that the
+// provider-metadata fallbacks in GetUsage can still supply it.
+func ComputeTokenUsage(usage *OpenRouterUsage) LanguageModelV3Usage {
+ promptTokens := float64(0)
+ completionTokens := float64(0)
+ cacheReadTokens := float64(0)
+ var cacheWriteTokens *float64
+ reasoningTokens := float64(0)
+
+ if usage != nil {
+ if usage.PromptTokens != nil {
+ promptTokens = *usage.PromptTokens
+ }
+ if usage.CompletionTokens != nil {
+ completionTokens = *usage.CompletionTokens
+ }
+ if usage.PromptTokensDetails != nil {
+ if usage.PromptTokensDetails.CachedTokens != nil {
+ cacheReadTokens = *usage.PromptTokensDetails.CachedTokens
+ }
+ cacheWriteTokens = usage.PromptTokensDetails.CacheWriteTokens
+ }
+ if usage.CompletionTokensDetails != nil && usage.CompletionTokensDetails.ReasoningTokens != nil {
+ reasoningTokens = *usage.CompletionTokensDetails.ReasoningTokens
+ }
+ }
+
+ noCache := promptTokens - cacheReadTokens
+ text := completionTokens - reasoningTokens
+ return LanguageModelV3Usage{
+ InputTokens: LanguageModelV3InputTokens{
+ Total: &promptTokens,
+ NoCache: &noCache,
+ CacheRead: &cacheReadTokens,
+ CacheWrite: cacheWriteTokens,
+ },
+ OutputTokens: LanguageModelV3OutputTokens{
+ Total: &completionTokens,
+ Text: &text,
+ Reasoning: &reasoningTokens,
+ },
+ Raw: rawOf(usage),
+ }
+}
+
+func rawOf(usage *OpenRouterUsage) json.RawMessage {
+ if usage == nil {
+ return nil
+ }
+ return usage.Raw
+}
+
+// ── stage 2: the flattened usage ─────────────────────────────────────────
+
+// InputTokenDetails is the flattened input breakdown.
+type InputTokenDetails struct {
+ NoCacheTokens *float64 `json:"noCacheTokens,omitempty"`
+ CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"`
+ CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"`
+}
+
+// OutputTokenDetails is the flattened output breakdown.
+type OutputTokenDetails struct {
+ TextTokens *float64 `json:"textTokens,omitempty"`
+ ReasoningTokens *float64 `json:"reasoningTokens,omitempty"`
+}
+
+// LanguageModelUsage is the flattened usage GetUsage consumes. The details
+// blocks are optional; the two trailing fields are flat aliases GetUsage
+// falls back to when the details are absent.
+type LanguageModelUsage struct {
+ InputTokens *float64 `json:"inputTokens,omitempty"`
+ InputTokenDetails *InputTokenDetails `json:"inputTokenDetails,omitempty"`
+ OutputTokens *float64 `json:"outputTokens,omitempty"`
+ OutputTokenDetails *OutputTokenDetails `json:"outputTokenDetails,omitempty"`
+ TotalTokens *float64 `json:"totalTokens,omitempty"`
+ Raw json.RawMessage `json:"raw,omitempty"`
+ ReasoningTokens *float64 `json:"reasoningTokens,omitempty"`
+ CachedInputTokens *float64 `json:"cachedInputTokens,omitempty"`
+}
+
+// AsLanguageModelUsage flattens the token groups. totalTokens is recomputed
+// as input + output; the provider's own total survives only inside Raw.
+func AsLanguageModelUsage(usage LanguageModelV3Usage) LanguageModelUsage {
+ return LanguageModelUsage{
+ InputTokens: usage.InputTokens.Total,
+ InputTokenDetails: &InputTokenDetails{
+ NoCacheTokens: usage.InputTokens.NoCache,
+ CacheReadTokens: usage.InputTokens.CacheRead,
+ CacheWriteTokens: usage.InputTokens.CacheWrite,
+ },
+ OutputTokens: usage.OutputTokens.Total,
+ OutputTokenDetails: &OutputTokenDetails{
+ TextTokens: usage.OutputTokens.Text,
+ ReasoningTokens: usage.OutputTokens.Reasoning,
+ },
+ TotalTokens: addTokenCounts(usage.InputTokens.Total, usage.OutputTokens.Total),
+ Raw: usage.Raw,
+ ReasoningTokens: usage.OutputTokens.Reasoning,
+ CachedInputTokens: usage.InputTokens.CacheRead,
+ }
+}
+
+// addTokenCounts is nil only when BOTH operands are absent; otherwise the
+// absent side counts as 0.
+func addTokenCounts(a, b *float64) *float64 {
+ if a == nil && b == nil {
+ return nil
+ }
+ sum := float64(0)
+ if a != nil {
+ sum += *a
+ }
+ if b != nil {
+ sum += *b
+ }
+ return &sum
+}
+
+// ── stage 3: usage and cost ──────────────────────────────────────────────
+
+// ProviderMetadata is the per-provider metadata map a response may carry.
+type ProviderMetadata map[string]map[string]any
+
+// UsageCache is the cache block of a usage result.
+type UsageCache struct {
+ Write float64 `json:"write"`
+ Read float64 `json:"read"`
+}
+
+// UsageTokens is the token block of a usage result. Total is optional.
+type UsageTokens struct {
+ Total *float64 `json:"total,omitempty"`
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+ Reasoning float64 `json:"reasoning"`
+ Cache UsageCache `json:"cache"`
+}
+
+// UsageResult is GetUsage's result: the call's cost in USD and its tokens.
+type UsageResult struct {
+ Cost float64 `json:"cost"`
+ Tokens UsageTokens `json:"tokens"`
+}
+
+// GetUsageInput is GetUsage's parameter object.
+type GetUsageInput struct {
+ Model Model
+ Usage LanguageModelUsage
+ Metadata ProviderMetadata
+}
+
+// GetUsage derives the billed token counts and the cost of one model call.
+// Cached input tokens are subtracted from the input count, since providers
+// report inputTokens inclusive of cache reads and writes.
+func GetUsage(input GetUsageInput) UsageResult {
+ usage := input.Usage
+
+ inputTokens := safe(orZero(usage.InputTokens))
+ outputTokens := safe(orZero(usage.OutputTokens))
+
+ var reasoningTokens float64
+ if usage.OutputTokenDetails != nil && usage.OutputTokenDetails.ReasoningTokens != nil {
+ reasoningTokens = *usage.OutputTokenDetails.ReasoningTokens
+ } else if usage.ReasoningTokens != nil {
+ reasoningTokens = *usage.ReasoningTokens
+ }
+ reasoningTokens = safe(reasoningTokens)
+
+ var cacheReadInputTokens float64
+ if usage.InputTokenDetails != nil && usage.InputTokenDetails.CacheReadTokens != nil {
+ cacheReadInputTokens = *usage.InputTokenDetails.CacheReadTokens
+ } else if usage.CachedInputTokens != nil {
+ cacheReadInputTokens = *usage.CachedInputTokens
+ }
+ cacheReadInputTokens = safe(cacheReadInputTokens)
+
+ cacheWriteInputTokens := safe(numberOf(cacheWriteCandidate(usage, input.Metadata)))
+
+ adjustedInputTokens := safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
+
+ tokens := UsageTokens{
+ Total: usage.TotalTokens,
+ Input: adjustedInputTokens,
+ Output: safe(outputTokens - reasoningTokens),
+ Reasoning: reasoningTokens,
+ Cache: UsageCache{
+ Write: cacheWriteInputTokens,
+ Read: cacheReadInputTokens,
+ },
+ }
+
+ rates := baseRates(input.Model.Cost)
+ if input.Model.Cost != nil && input.Model.Cost.ExperimentalOver200K != nil &&
+ tokens.Input+tokens.Cache.Read > 200_000 {
+ rates = over200KRates(input.Model.Cost.ExperimentalOver200K)
+ }
+
+ return UsageResult{
+ Cost: safe(cost(tokens, rates)),
+ Tokens: tokens,
+ }
+}
+
+// cacheWriteCandidate finds the cache-write token count: the flattened
+// details first, then the provider-specific metadata keys some providers use
+// instead. A nil result means none was reported.
+func cacheWriteCandidate(usage LanguageModelUsage, metadata ProviderMetadata) any {
+ if usage.InputTokenDetails != nil && usage.InputTokenDetails.CacheWriteTokens != nil {
+ return *usage.InputTokenDetails.CacheWriteTokens
+ }
+ if value, ok := metadataGet(metadata, "anthropic", "cacheCreationInputTokens"); ok {
+ return value
+ }
+ if value, ok := metadataGet(metadata, "vertex", "cacheCreationInputTokens"); ok {
+ return value
+ }
+ if value, ok := metadataGetNested(metadata, "bedrock", "usage", "cacheWriteInputTokens"); ok {
+ return value
+ }
+ if value, ok := metadataGetNested(metadata, "venice", "usage", "cacheCreationInputTokens"); ok {
+ return value
+ }
+ return nil
+}
+
+// metadataGet is metadata[provider][key]; ok=false when either level is
+// absent or null.
+func metadataGet(metadata ProviderMetadata, provider, key string) (any, bool) {
+ inner, ok := metadata[provider]
+ if !ok || inner == nil {
+ return nil, false
+ }
+ value, ok := inner[key]
+ if !ok || value == nil {
+ return nil, false
+ }
+ return value, true
+}
+
+// metadataGetNested is metadata[provider][outer][key].
+func metadataGetNested(metadata ProviderMetadata, provider, outer, key string) (any, bool) {
+ middle, ok := metadataGet(metadata, provider, outer)
+ if !ok {
+ return nil, false
+ }
+ object, ok := middle.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ value, ok := object[key]
+ if !ok || value == nil {
+ return nil, false
+ }
+ return value, true
+}
+
+// numberOf reads a token count out of a decoded JSON value. Anything that is
+// not a number (or a numeric string) counts as 0.
+func numberOf(value any) float64 {
+ switch typed := value.(type) {
+ case nil:
+ return 0
+ case float64:
+ return typed
+ case float32:
+ return float64(typed)
+ case int:
+ return float64(typed)
+ case int64:
+ return float64(typed)
+ case json.Number:
+ f, _ := typed.Float64()
+ return f
+ case string:
+ f, err := strconv.ParseFloat(typed, 64)
+ if err != nil {
+ return 0
+ }
+ return f
+ }
+ return 0
+}
+
+func orZero(value *float64) float64 {
+ if value == nil {
+ return 0
+ }
+ return *value
+}
+
+// costRates is the resolved price table in $/Mtok; an absent rate is 0.
+type costRates struct {
+ input float64
+ output float64
+ cacheRead float64
+ cacheWrite float64
+}
+
+func baseRates(cost *ModelCost) costRates {
+ if cost == nil {
+ return costRates{}
+ }
+ rates := costRates{input: cost.Input, output: cost.Output}
+ if cost.Cache != nil {
+ rates.cacheRead = cost.Cache.Read
+ rates.cacheWrite = cost.Cache.Write
+ }
+ return rates
+}
+
+func over200KRates(cost *Over200KCost) costRates {
+ rates := costRates{input: cost.Input, output: cost.Output}
+ if cost.Cache != nil {
+ rates.cacheRead = cost.Cache.Read
+ rates.cacheWrite = cost.Cache.Write
+ }
+ return rates
+}
+
+// cost prices the token block in USD. Reasoning tokens are billed at the
+// output rate because catalogs carry no separate reasoning price.
+func cost(tokens UsageTokens, rates costRates) float64 {
+ return (tokens.Input*rates.input +
+ tokens.Output*rates.output +
+ tokens.Cache.Read*rates.cacheRead +
+ tokens.Cache.Write*rates.cacheWrite +
+ tokens.Reasoning*rates.output) / 1_000_000
+}
diff --git a/internal/seniordev/engine/msgmodel/convertmodelmessages.go b/internal/seniordev/engine/msgmodel/convertmodelmessages.go
new file mode 100644
index 0000000000..d7eea3b20f
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/convertmodelmessages.go
@@ -0,0 +1,390 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// ConvertToModelMessages turns UI messages into the model-facing message
+// list. The load-bearing rule is that a `step-start` part FLUSHES the current
+// assistant block, so one multi-step assistant UIMessage expands into an
+// alternating assistant / tool / assistant / tool… run of ModelMessages.
+//
+// Deliberate omissions, all unreachable from senior-dev:
+// - custom data-part conversion: senior-dev never supplies one, so `data-*`
+// parts are dropped.
+// - `source-url` / `source-document` parts match no branch on an assistant
+// message: they are ignored AND do not break the block. They simply never
+// enter `block`.
+
+// ToolModelOutputFn converts a tool's stored output into the shape the model
+// sees. `output` is the raw JSON value; a nil `output` is absent.
+type ToolModelOutputFn func(toolCallID string, input RawValue, output RawValue) ToolOutput
+
+// ConvertOptions are the conversion options.
+type ConvertOptions struct {
+ IgnoreIncompleteToolCalls bool
+ Tools map[string]ToolModelOutputFn
+}
+
+func (o *ConvertOptions) tool(name string) ToolModelOutputFn {
+ if o == nil || o.Tools == nil {
+ return nil
+ }
+ return o.Tools[name]
+}
+
+// ConvertToModelMessages is the conversion described above.
+func ConvertToModelMessages(messages []UIMessage, options *ConvertOptions) ([]ModelMessage, error) {
+ modelMessages := []ModelMessage{}
+
+ if options != nil && options.IgnoreIncompleteToolCalls {
+ // A shallow message copy with incomplete tool parts filtered.
+ filtered := make([]UIMessage, 0, len(messages))
+ for _, message := range messages {
+ parts := make([]UIPart, 0, len(message.Parts))
+ for _, part := range message.Parts {
+ if part.isTool() && (part.State == UIToolInputStreaming || part.State == UIToolInputAvailable) {
+ continue
+ }
+ parts = append(parts, part)
+ }
+ message.Parts = parts
+ filtered = append(filtered, message)
+ }
+ messages = filtered
+ }
+
+ for _, message := range messages {
+ switch message.Role {
+ case "system":
+ // Non-text parts are silently filtered, text is joined with ""
+ // and providerMetadata from every text part is shallow-merged one
+ // level.
+ var content bytes.Buffer
+ merged := []RawField{}
+ for _, part := range message.Parts {
+ if !part.isText() {
+ continue
+ }
+ content.WriteString(part.Text)
+ if len(part.ProviderMetadata) == 0 || string(part.ProviderMetadata) == "null" {
+ continue
+ }
+ for _, f := range RawObject(part.ProviderMetadata).Fields() {
+ merged = upsertField(merged, f)
+ }
+ }
+ msg := ModelMessage{Role: "system", Content: content.String()}
+ if len(merged) > 0 {
+ msg.ProviderOptions = encodeFields(merged)
+ }
+ modelMessages = append(modelMessages, msg)
+
+ case "user":
+ content := []any{}
+ for _, part := range message.Parts {
+ switch {
+ case part.isText():
+ content = append(content, TextContent{
+ Type: "text",
+ Text: part.Text,
+ ProviderOptions: nonNull(part.ProviderMetadata),
+ })
+ case part.isFile():
+ content = append(content, FileContent{
+ Type: "file",
+ MediaType: part.MediaType,
+ Filename: part.Filename,
+ Data: part.URL,
+ ProviderOptions: nonNull(part.ProviderMetadata),
+ })
+ }
+ // Every other part kind (reasoning, tool-*, source-*,
+ // step-start, data-*) is dropped.
+ }
+ modelMessages = append(modelMessages, ModelMessage{Role: "user", Content: content})
+
+ case "assistant":
+ var block []UIPart
+ processBlock := func() error {
+ if len(block) == 0 {
+ return nil
+ }
+ content := []any{}
+ for _, part := range block {
+ switch {
+ case part.isText():
+ content = append(content, TextContent{
+ Type: "text",
+ Text: part.Text,
+ ProviderOptions: nonNull(part.ProviderMetadata),
+ })
+ case part.isFile():
+ content = append(content, FileContent{
+ Type: "file",
+ MediaType: part.MediaType,
+ Filename: part.Filename,
+ Data: part.URL,
+ ProviderOptions: nonNull(part.ProviderMetadata),
+ })
+ case part.isReasoning():
+ content = append(content, ReasoningContent{
+ Type: "reasoning",
+ Text: part.Text,
+ // Set unconditionally: an explicit null stays a
+ // null; only an absent value disappears.
+ ProviderOptions: part.ProviderMetadata,
+ })
+ case part.isTool():
+ toolName := part.ResolveToolName()
+ if part.State == UIToolInputStreaming {
+ // Emits nothing at all.
+ break
+ }
+ content = append(content, ToolCallContent{
+ Type: "tool-call",
+ ToolCallID: part.ToolCallID,
+ ToolName: toolName,
+ Input: toolCallInput(part),
+ ProviderExecuted: part.ProviderExecuted,
+ ProviderOptions: nonNull(part.CallProviderMetadata),
+ })
+ if isStrictTrue(part.ProviderExecuted) &&
+ (part.State == UIToolOutputAvailable || part.State == UIToolOutputError) {
+ // Provider-executed results stay INSIDE the
+ // assistant message, with errorMode
+ // "json" (contrast the tool-role message below).
+ resultMeta := part.ResultProviderMetadata
+ if len(resultMeta) == 0 || string(resultMeta) == "null" {
+ resultMeta = part.CallProviderMetadata
+ }
+ errorMode := errorModeNone
+ output := part.Output
+ if part.State == UIToolOutputError {
+ errorMode = errorModeJSON
+ output = jsonString(part.ErrorText)
+ }
+ content = append(content, ToolResultContent{
+ Type: "tool-result",
+ ToolCallID: part.ToolCallID,
+ ToolName: toolName,
+ Output: createToolModelOutput(part.ToolCallID, part.Input, output, options.tool(toolName), errorMode),
+ ProviderOptions: nonNull(resultMeta),
+ })
+ }
+ case part.isData():
+ // No data-part conversion is supplied; dropped.
+ default:
+ // Unreachable: `block` only ever receives the five
+ // kinds above.
+ return fmt.Errorf("Unsupported part: %s", part.Type)
+ }
+ }
+ modelMessages = append(modelMessages, ModelMessage{Role: "assistant", Content: content})
+
+ // Provider-executed parts are excluded from the tool-role
+ // message: their results already sit in the assistant message.
+ toolParts := make([]UIPart, 0, len(block))
+ for _, part := range block {
+ if !part.isTool() {
+ continue
+ }
+ if !isStrictTrue(part.ProviderExecuted) {
+ toolParts = append(toolParts, part)
+ }
+ }
+ if len(toolParts) > 0 {
+ toolContent := []any{}
+ for _, toolPart := range toolParts {
+ switch toolPart.State {
+ case UIToolOutputError, UIToolOutputAvailable:
+ toolName := toolPart.ResolveToolName()
+ errorMode := errorModeNone
+ output := toolPart.Output
+ if toolPart.State == UIToolOutputError {
+ errorMode = errorModeText
+ output = jsonString(toolPart.ErrorText)
+ }
+ toolContent = append(toolContent, ToolResultContent{
+ Type: "tool-result",
+ ToolCallID: toolPart.ToolCallID,
+ ToolName: toolName,
+ Output: createToolModelOutput(toolPart.ToolCallID, toolPart.Input, output, options.tool(toolName), errorMode),
+ ProviderOptions: nonNull(toolPart.CallProviderMetadata),
+ })
+ }
+ }
+ // Pushed only if non-empty. A block whose tool parts are
+ // all input-available would yield an assistant message
+ // with a dangling tool-call and no tool message;
+ // ToModelMessages prevents that by replaying pending and
+ // running tools as errors.
+ if len(toolContent) > 0 {
+ modelMessages = append(modelMessages, ModelMessage{Role: "tool", Content: toolContent})
+ }
+ }
+ block = nil
+ return nil
+ }
+
+ for _, part := range message.Parts {
+ if part.isText() || part.isReasoning() || part.isFile() || part.isTool() || part.isData() {
+ block = append(block, part)
+ continue
+ }
+ if part.Type == "step-start" {
+ if err := processBlock(); err != nil {
+ return nil, err
+ }
+ }
+ }
+ if err := processBlock(); err != nil {
+ return nil, err
+ }
+
+ default:
+ return nil, &MessageConversionError{Message: "Unsupported role: " + message.Role}
+ }
+ }
+
+ return modelMessages, nil
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────
+
+const (
+ errorModeNone = ""
+ errorModeText = "text"
+ errorModeJSON = "json"
+)
+
+// toolCallInput is the input recorded on the call. On an output-error part a
+// null or absent input falls through to rawInput.
+func toolCallInput(part UIPart) RawValue {
+ if part.State != UIToolOutputError {
+ return part.Input
+ }
+ if len(part.Input) > 0 && string(part.Input) != "null" {
+ return part.Input
+ }
+ if len(part.RawInput) > 0 {
+ return part.RawInput
+ }
+ return nil
+}
+
+// nonNull returns nil for an absent or explicitly-null value, so the key is
+// omitted.
+func nonNull(v RawValue) RawValue {
+ if len(v) == 0 || string(bytes.TrimSpace(v)) == "null" {
+ return nil
+ }
+ return v
+}
+
+func isStrictTrue(v RawValue) bool {
+ return string(bytes.TrimSpace(v)) == "true"
+}
+
+func jsonString(s string) RawValue {
+ raw, err := jsonutil.Marshal(s)
+ if err != nil {
+ return nil
+ }
+ return raw
+}
+
+// createToolModelOutput builds the tool-result output the model sees: error
+// text, error JSON, the tool's own converter, or a plain text/json value.
+func createToolModelOutput(toolCallID string, input, output RawValue, tool ToolModelOutputFn, errorMode string) ToolOutput {
+ switch errorMode {
+ case errorModeText:
+ return ToolOutput{Type: "error-text", Value: getErrorMessage(output)}
+ case errorModeJSON:
+ return ToolOutput{Type: "error-json", Value: toJSONValue(output)}
+ }
+ if tool != nil {
+ return tool(toolCallID, input, output)
+ }
+ if s, ok := asJSONString(output); ok {
+ return ToolOutput{Type: "text", Value: s}
+ }
+ return ToolOutput{Type: "json", Value: toJSONValue(output)}
+}
+
+// getErrorMessage renders a stored error output as text: "unknown error" for
+// null or absent, the string itself, or the compact JSON otherwise.
+func getErrorMessage(output RawValue) any {
+ if len(output) == 0 || string(bytes.TrimSpace(output)) == "null" {
+ return "unknown error"
+ }
+ if s, ok := asJSONString(output); ok {
+ return s
+ }
+ return string(compactJSON(output))
+}
+
+// toJSONValue maps an absent value to JSON null.
+func toJSONValue(output RawValue) any {
+ if len(output) == 0 {
+ return json.RawMessage("null")
+ }
+ return output
+}
+
+func asJSONString(raw RawValue) (string, bool) {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || trimmed[0] != '"' {
+ return "", false
+ }
+ var s string
+ if err := json.Unmarshal(trimmed, &s); err != nil {
+ return "", false
+ }
+ return s, true
+}
+
+func compactJSON(raw []byte) []byte {
+ var buf bytes.Buffer
+ if err := json.Compact(&buf, raw); err != nil {
+ return raw
+ }
+ return buf.Bytes()
+}
+
+// upsertField is one level of `{...acc, ...part.providerMetadata}`: a repeated
+// key keeps its ORIGINAL position and takes the newer value.
+func upsertField(acc []RawField, f RawField) []RawField {
+ for i := range acc {
+ if acc[i].Key == f.Key {
+ acc[i].Value = f.Value
+ return acc
+ }
+ }
+ return append(acc, f)
+}
+
+func encodeFields(fields []RawField) RawValue {
+ var buf bytes.Buffer
+ buf.WriteByte('{')
+ for i, f := range fields {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ key, err := jsonutil.Marshal(f.Key)
+ if err != nil {
+ return nil
+ }
+ buf.Write(key)
+ buf.WriteByte(':')
+ buf.Write(f.Value)
+ }
+ buf.WriteByte('}')
+ return buf.Bytes()
+}
diff --git a/internal/seniordev/engine/msgmodel/cursor.go b/internal/seniordev/engine/msgmodel/cursor.go
new file mode 100644
index 0000000000..b9718a6bd6
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/cursor.go
@@ -0,0 +1,64 @@
+//go:build !windows
+
+// Cursor encoding: a pagination cursor is `{id, time}` encoded as unpadded
+// RFC 4648 URL-safe base64.
+package msgmodel
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "math"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+type Cursor struct {
+ ID string `json:"id"`
+ Time float64 `json:"time"`
+}
+
+func EncodeCursor(input Cursor) (string, error) {
+ raw, err := jsonutil.Marshal(input)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(raw), nil
+}
+
+func DecodeCursor(input string) (Cursor, error) {
+ raw, err := base64.RawURLEncoding.DecodeString(input)
+ if err != nil {
+ return Cursor{}, err
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ return Cursor{}, err
+ }
+ idRaw, idOK := fields["id"]
+ timeRaw, timeOK := fields["time"]
+ if !idOK || !timeOK || bytes.Equal(bytes.TrimSpace(idRaw), []byte("null")) ||
+ bytes.Equal(bytes.TrimSpace(timeRaw), []byte("null")) {
+ return Cursor{}, errors.New("msgmodel: cursor requires id and time")
+ }
+ var cursor Cursor
+ if err := json.Unmarshal(idRaw, &cursor.ID); err != nil {
+ return Cursor{}, err
+ }
+ if err := json.Unmarshal(timeRaw, &cursor.Time); err != nil {
+ return Cursor{}, err
+ }
+ if err := validateCursor(cursor); err != nil {
+ return Cursor{}, err
+ }
+ return cursor, nil
+}
+
+func validateCursor(cursor Cursor) error {
+ n := float64(cursor.Time)
+ if math.IsNaN(n) || math.IsInf(n, 0) || n < 0 {
+ return errors.New("msgmodel: cursor time must be finite and non-negative")
+ }
+ return nil
+}
diff --git a/internal/seniordev/engine/msgmodel/events.go b/internal/seniordev/engine/msgmodel/events.go
new file mode 100644
index 0000000000..65cd6f0a9c
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/events.go
@@ -0,0 +1,47 @@
+//go:build !windows
+
+// Event names and payloads. The runtime's bus owns registration and
+// delivery; this package owns the public names, versions, aggregate key, and
+// wire payload shapes.
+package msgmodel
+
+const (
+ EventMessageUpdated = "message.updated"
+ EventMessageRemoved = "message.removed"
+ EventMessagePartUpdated = "message.part.updated"
+ EventMessagePartDelta = "message.part.delta"
+ EventMessagePartRemoved = "message.part.removed"
+
+ SyncEventVersion = 1
+ SyncAggregateKey = "sessionID"
+)
+
+type UpdatedEvent struct {
+ SessionID string `json:"sessionID"`
+ Info Info `json:"info"`
+}
+
+type RemovedEvent struct {
+ SessionID string `json:"sessionID"`
+ MessageID string `json:"messageID"`
+}
+
+type PartUpdatedEvent struct {
+ SessionID string `json:"sessionID"`
+ Part Part `json:"part"`
+ Time uint64 `json:"time"`
+}
+
+type PartDeltaEvent struct {
+ SessionID string `json:"sessionID"`
+ MessageID string `json:"messageID"`
+ PartID string `json:"partID"`
+ Field string `json:"field"`
+ Delta string `json:"delta"`
+}
+
+type PartRemovedEvent struct {
+ SessionID string `json:"sessionID"`
+ MessageID string `json:"messageID"`
+ PartID string `json:"partID"`
+}
diff --git a/internal/seniordev/engine/msgmodel/filter.go b/internal/seniordev/engine/msgmodel/filter.go
new file mode 100644
index 0000000000..5e02357837
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/filter.go
@@ -0,0 +1,135 @@
+//go:build !windows
+
+package msgmodel
+
+// FilterCompacted projects a session onto what the model should see: a
+// newest-first walk over the messages that stops at the last completed
+// compaction, then (when the compaction names a `tail_start_id` that sits
+// BEFORE it) rotates the summary block in front of the retained tail.
+//
+// `msgs` must arrive newest-first; the result is chronological.
+//
+// The step loop mutates the returned parts in place (WrapLateUserText), so
+// the caller needs parts it owns. This function does NOT deep-copy; the
+// storage layer that feeds it must hand over fresh values.
+func FilterCompacted(msgs []WithParts) []WithParts {
+ result := []WithParts{}
+ completed := map[string]bool{}
+ var retain *string
+
+ for _, msg := range msgs {
+ result = append(result, msg)
+ if retain != nil {
+ if msg.Info.MessageID() == *retain {
+ break
+ }
+ continue
+ }
+ if user, ok := msg.Info.(User); ok && completed[user.ID] {
+ part := findCompactionPart(msg.Parts)
+ if part == nil {
+ continue
+ }
+ if part.TailStartID == nil || *part.TailStartID == "" {
+ // An empty tail id counts as no tail.
+ break
+ }
+ retain = part.TailStartID
+ if msg.Info.MessageID() == *retain {
+ break
+ }
+ continue
+ }
+ if assistant, ok := msg.Info.(Assistant); ok &&
+ boolValue(assistant.Summary) &&
+ assistant.Finish != nil && *assistant.Finish != "" &&
+ assistant.Error == nil {
+ completed[assistant.ParentID] = true
+ }
+ }
+
+ reverseWithParts(result)
+
+ compactionIndex := -1
+ for i := len(result) - 1; i >= 0; i-- {
+ if _, ok := result[i].Info.(User); !ok {
+ continue
+ }
+ if findCompactionWithTail(result[i].Parts) != nil {
+ compactionIndex = i
+ break
+ }
+ }
+ if compactionIndex < 0 {
+ return result
+ }
+ compaction := result[compactionIndex]
+ part := findCompactionWithTail(compaction.Parts)
+
+ summaryIndex := -1
+ for i, msg := range result {
+ if i <= compactionIndex {
+ continue
+ }
+ assistant, ok := msg.Info.(Assistant)
+ if !ok {
+ continue
+ }
+ // The same rule that marked the compaction completed above: an
+ // errored summary attempt (a transport failure, a rejected draft) is
+ // never the boundary. Without this check a failed first attempt
+ // sitting before the accepted one would be picked here, and the tail
+ // would be rotated in front of the real summary instead of after it.
+ if boolValue(assistant.Summary) && assistant.Error == nil &&
+ assistant.ParentID == compaction.Info.MessageID() {
+ summaryIndex = i
+ break
+ }
+ }
+
+ tailIndex := -1
+ if part != nil && part.TailStartID != nil && *part.TailStartID != "" {
+ for i, msg := range result {
+ if msg.Info.MessageID() == *part.TailStartID {
+ tailIndex = i
+ break
+ }
+ }
+ }
+
+ if tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex {
+ out := make([]WithParts, 0, len(result))
+ out = append(out, result[compactionIndex:summaryIndex+1]...)
+ out = append(out, result[tailIndex:compactionIndex]...)
+ out = append(out, result[summaryIndex+1:]...)
+ return out
+ }
+ return result
+}
+
+func findCompactionPart(parts Parts) *CompactionPart {
+ for _, raw := range parts {
+ if part, ok := raw.(CompactionPart); ok {
+ return &part
+ }
+ }
+ return nil
+}
+
+// findCompactionWithTail finds a compaction part whose tail id is present at
+// all; unlike the walk above it accepts an empty-string tail id.
+func findCompactionWithTail(parts Parts) *CompactionPart {
+ for _, raw := range parts {
+ part, ok := raw.(CompactionPart)
+ if ok && part.TailStartID != nil {
+ return &part
+ }
+ }
+ return nil
+}
+
+func reverseWithParts(s []WithParts) {
+ for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
+ s[i], s[j] = s[j], s[i]
+ }
+}
diff --git a/internal/seniordev/engine/msgmodel/filter_test.go b/internal/seniordev/engine/msgmodel/filter_test.go
new file mode 100644
index 0000000000..8b830b692a
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/filter_test.go
@@ -0,0 +1,114 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "testing"
+)
+
+func filterUser(id string, parts ...Part) WithParts {
+ return WithParts{
+ Info: User{MessageBase: MessageBase{ID: id, SessionID: "ses"}},
+ Parts: parts,
+ }
+}
+
+func filterAssistant(id, parent string, summary bool, failed error) WithParts {
+ finish := "stop"
+ info := Assistant{
+ MessageBase: MessageBase{ID: id, SessionID: "ses"},
+ ParentID: parent, Finish: &finish,
+ }
+ if summary {
+ flag := true
+ info.Summary = &flag
+ }
+ if failed != nil {
+ converted := NewUnknownError(failed.Error())
+ info.Error = &converted
+ errorFinish := "error"
+ info.Finish = &errorFinish
+ }
+ return WithParts{
+ Info: info,
+ Parts: Parts{TextPart{
+ PartBase: PartBase{ID: "p_" + id, SessionID: "ses", MessageID: id},
+ Text: "text " + id,
+ }},
+ }
+}
+
+func ids(messages []WithParts) []string {
+ out := make([]string, 0, len(messages))
+ for _, message := range messages {
+ out = append(out, message.Info.MessageID())
+ }
+ return out
+}
+
+func equalIDs(got, want []string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// chronological builds: u0, a0, a1, a2 (tail starts at a1), the compaction
+// user message uc, then the summary attempts, then the auto-continue user.
+func compactedSession(attempts ...WithParts) []WithParts {
+ tail := "a1"
+ messages := []WithParts{
+ filterUser("u0"),
+ filterAssistant("a0", "u0", false, nil),
+ filterAssistant("a1", "u0", false, nil),
+ filterAssistant("a2", "u0", false, nil),
+ filterUser("uc", CompactionPart{
+ PartBase: PartBase{ID: "pc", SessionID: "ses", MessageID: "uc"},
+ Auto: true, TailStartID: &tail,
+ }),
+ }
+ messages = append(messages, attempts...)
+ return append(messages, filterUser("ucont"))
+}
+
+func newestFirst(messages []WithParts) []WithParts {
+ out := make([]WithParts, len(messages))
+ for i := range messages {
+ out[len(messages)-1-i] = messages[i]
+ }
+ return out
+}
+
+func TestFilterCompactedRotatesTailAfterTheSummary(t *testing.T) {
+ session := compactedSession(filterAssistant("as", "uc", true, nil))
+ got := ids(FilterCompacted(newestFirst(session)))
+ want := []string{"uc", "as", "a1", "a2", "ucont"}
+ if !equalIDs(got, want) {
+ t.Fatalf("projection = %v, want %v", got, want)
+ }
+}
+
+// An errored summary attempt sitting before the accepted one (a transport
+// failure retried by the run layer) must not be chosen as the boundary: the
+// tail has to land AFTER the accepted summary, exactly as it does when the
+// first attempt succeeds.
+func TestFilterCompactedSkipsErroredSummaryAttemptWhenRotating(t *testing.T) {
+ session := compactedSession(
+ filterAssistant("afail", "uc", true, errString("unexpected EOF")),
+ filterAssistant("as", "uc", true, nil),
+ )
+ got := ids(FilterCompacted(newestFirst(session)))
+ want := []string{"uc", "afail", "as", "a1", "a2", "ucont"}
+ if !equalIDs(got, want) {
+ t.Fatalf("projection = %v, want %v", got, want)
+ }
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
diff --git a/internal/seniordev/engine/msgmodel/fromerror.go b/internal/seniordev/engine/msgmodel/fromerror.go
new file mode 100644
index 0000000000..adfe88ac13
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/fromerror.go
@@ -0,0 +1,203 @@
+//go:build !windows
+
+// Error conversion. Stream-error parsing lives here because it is a pure JSON
+// decision.
+package msgmodel
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// Marker errors FromError classifies by type.
+type AbortFailure struct{ Message string }
+
+func (e AbortFailure) Error() string { return e.Message }
+
+type OutputLengthFailure struct{}
+
+func (OutputLengthFailure) Error() string { return ErrNameMessageOutputLength }
+
+// FromError classifies a failure value into the persisted AssistantError
+// shape. `value` is the raw `error` payload of a model stream, a Go error, or
+// one of the marker types above.
+func FromError(value any) AssistantError {
+ switch e := value.(type) {
+ case AbortFailure:
+ return NewMessageAbortedError(e.Message)
+ case *AbortFailure:
+ if e != nil {
+ return NewMessageAbortedError(e.Message)
+ }
+ case OutputLengthFailure, *OutputLengthFailure:
+ return NewMessageOutputLengthError()
+ case AssistantError:
+ if e.Name == ErrNameMessageOutputLength {
+ return e
+ }
+ case *AssistantError:
+ if e != nil && e.Name == ErrNameMessageOutputLength {
+ return *e
+ }
+ }
+
+ if err, ok := value.(error); ok {
+ // Recognize the OpenRouter in-band shape first so a provider failure
+ // wrapped in a Go error classifies as an APIError.
+ if apiErr := openRouterInBandAPIError(streamJSON(err.Error())); apiErr != nil {
+ return NewAPIError(*apiErr)
+ }
+ return NewUnknownError(errorMessage(err))
+ }
+ if parsed := ParseStreamError(value); parsed != nil {
+ if parsed.Type == "context_overflow" {
+ return NewContextOverflowError(ContextOverflowErrorData{
+ Message: parsed.Message, ResponseBody: parsed.ResponseBody,
+ })
+ }
+ return NewAPIError(APIError{
+ Message: parsed.Message, IsRetryable: parsed.IsRetryable,
+ ResponseBody: parsed.ResponseBody,
+ })
+ }
+ // The raw `error` field of an OpenRouter chunk arrives here as a
+ // json.RawMessage. ParseStreamError has already declined it (no envelope);
+ // recognize the bare in-band shape before it degrades to UnknownError.
+ if apiErr := openRouterInBandAPIError(streamJSON(value)); apiErr != nil {
+ return NewAPIError(*apiErr)
+ }
+ raw, err := jsonutil.Marshal(value)
+ if err != nil {
+ return NewUnknownError("")
+ }
+ return NewUnknownError(string(raw))
+}
+
+func errorMessage(err error) string {
+ if err == nil {
+ return "Error"
+ }
+ if message := err.Error(); message != "" {
+ return message
+ }
+ return fmt.Sprintf("%T", err)
+}
+
+type ParsedStreamError struct {
+ Type string
+ Message string
+ IsRetryable bool
+ ResponseBody *string
+}
+
+func ParseStreamError(input any) *ParsedStreamError {
+ raw := streamJSON(input)
+ if len(raw) == 0 {
+ return nil
+ }
+ var outer json.RawMessage
+ if err := json.Unmarshal(raw, &outer); err != nil {
+ return nil
+ }
+ body := compactJSONValue(outer)
+ var probe struct {
+ Message any `json:"message"`
+ }
+ if err := json.Unmarshal(body, &probe); err == nil {
+ if message, ok := probe.Message.(string); ok {
+ nested := streamJSON(message)
+ if len(nested) > 0 {
+ var nestedValue json.RawMessage
+ if json.Unmarshal(nested, &nestedValue) == nil && isJSONObject(nestedValue) {
+ body = compactJSONValue(nestedValue)
+ }
+ }
+ }
+ }
+
+ var envelope struct {
+ Type string `json:"type"`
+ Error struct {
+ Code string `json:"code"`
+ Message any `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil || envelope.Type != "error" {
+ return nil
+ }
+ response := string(body)
+ message, _ := envelope.Error.Message.(string)
+ result := &ParsedStreamError{ResponseBody: &response}
+ switch envelope.Error.Code {
+ case "context_length_exceeded":
+ result.Type = "context_overflow"
+ result.Message = "Input exceeds context window of this model"
+ case "insufficient_quota":
+ result.Type = "api_error"
+ result.Message = "Quota exceeded. Check your plan and billing details."
+ case "usage_not_included":
+ result.Type = "api_error"
+ result.Message = "Usage is not included in the current plan."
+ case "invalid_prompt":
+ result.Type = "api_error"
+ result.Message = message
+ if result.Message == "" {
+ result.Message = "Invalid prompt."
+ }
+ case "server_is_overloaded", "server_error":
+ result.Type = "api_error"
+ result.Message = message
+ if result.Message == "" {
+ result.Message = "Server error."
+ }
+ result.IsRetryable = true
+ default:
+ return nil
+ }
+ return result
+}
+
+func streamJSON(input any) []byte {
+ switch value := input.(type) {
+ case json.RawMessage:
+ if json.Valid(value) {
+ return value
+ }
+ case RawObject:
+ if json.Valid(value) {
+ return value
+ }
+ case []byte:
+ if json.Valid(value) {
+ return value
+ }
+ case string:
+ trimmed := strings.TrimSpace(value)
+ if json.Valid([]byte(trimmed)) {
+ return []byte(trimmed)
+ }
+ default:
+ raw, err := jsonutil.Marshal(value)
+ if err == nil && json.Valid(raw) {
+ return raw
+ }
+ }
+ return nil
+}
+
+func compactJSONValue(raw []byte) []byte {
+ var buffer bytes.Buffer
+ if err := json.Compact(&buffer, raw); err != nil {
+ return raw
+ }
+ return buffer.Bytes()
+}
+
+func isJSONObject(raw []byte) bool {
+ trimmed := bytes.TrimSpace(raw)
+ return len(trimmed) > 0 && trimmed[0] == '{'
+}
diff --git a/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go b/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go
new file mode 100644
index 0000000000..b95f8882ca
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go
@@ -0,0 +1,91 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+)
+
+// The in-band payload OpenRouter sends when an upstream provider drops the
+// connection mid-stream. It arrives two ways: as the raw `error` field of a chunk
+// (json.RawMessage, via FromStreamError), or wrapped in a Go error. Both must
+// classify as an APIError with the status attached, or the run layer cannot
+// offer its bounded fresh-turn recovery and one transient blip ends the run.
+const openrouter502Body = `{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}}`
+
+func assertInBand502(t *testing.T, got AssistantError) {
+ t.Helper()
+ if got.Name != ErrNameAPI {
+ t.Fatalf("classified as %q, want %q -- the run cannot recover this", got.Name, ErrNameAPI)
+ }
+ var data APIError
+ if err := json.Unmarshal(got.Data, &data); err != nil {
+ t.Fatal(err)
+ }
+ if data.StatusCode == nil || *data.StatusCode != 502 {
+ t.Fatalf("StatusCode = %v, want 502 -- the run classifier keys on it", data.StatusCode)
+ }
+ if data.Message != "Network connection lost." {
+ t.Fatalf("Message = %q", data.Message)
+ }
+ if data.ResponseBody == nil || *data.ResponseBody != openrouter502Body {
+ t.Fatalf("ResponseBody not preserved: %v", data.ResponseBody)
+ }
+}
+
+func TestOpenRouterInBand502ClassifiesAsAPIError(t *testing.T) {
+ t.Run("as the raw error field of a chunk (the runtime path)", func(t *testing.T) {
+ assertInBand502(t, FromError(json.RawMessage(openrouter502Body)))
+ })
+
+ t.Run("wrapped in a Go error", func(t *testing.T) {
+ assertInBand502(t, FromError(errors.New(openrouter502Body)))
+ })
+}
+
+// The recognizer classifies shape only; policy stays with the run. A 4xx
+// in the same shape must carry its status and NOT be marked retryable here.
+func TestOpenRouterInBand4xxCarriesStatusWithoutRetryFlag(t *testing.T) {
+ got := FromError(json.RawMessage(`{"code":400,"message":"bad request"}`))
+ if got.Name != ErrNameAPI {
+ t.Fatalf("classified as %q, want %q", got.Name, ErrNameAPI)
+ }
+ var data APIError
+ if err := json.Unmarshal(got.Data, &data); err != nil {
+ t.Fatal(err)
+ }
+ if data.StatusCode == nil || *data.StatusCode != 400 {
+ t.Fatalf("StatusCode = %v, want 400", data.StatusCode)
+ }
+ if data.IsRetryable {
+ t.Fatal("recognizer must not set IsRetryable; retry policy belongs to the run")
+ }
+}
+
+// Shapes the recognizer must decline, so nothing that previously classified
+// changes behaviour.
+func TestOpenRouterInBandRecognizerDeclines(t *testing.T) {
+ for name, payload := range map[string]string{
+ "enveloped stream error": `{"type":"error","error":{"code":"overloaded_error","message":"x"}}`,
+ "nested error object": `{"error":{"code":502,"message":"x"}}`,
+ "string code": `{"code":"NOT_A_NUMBER","message":"x"}`,
+ "no message": `{"code":502}`,
+ "non-http code": `{"code":-32000,"message":"jsonrpc-style"}`,
+ "fractional code": `{"code":502.5,"message":"x"}`,
+ "not an object": `"Network connection lost."`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ if apiErr := openRouterInBandAPIError([]byte(payload)); apiErr != nil {
+ t.Fatalf("recognized %s as %+v; must decline", payload, apiErr)
+ }
+ })
+ }
+ // And a plain Go error with a non-JSON message still degrades to
+ // UnknownError exactly as before.
+ got := FromError(errors.New("Network connection lost."))
+ if got.Name != ErrNameUnknown {
+ t.Fatalf("plain text error classified as %q, want %q", got.Name, ErrNameUnknown)
+ }
+}
diff --git a/internal/seniordev/engine/msgmodel/message.go b/internal/seniordev/engine/msgmodel/message.go
new file mode 100644
index 0000000000..59dd66308d
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/message.go
@@ -0,0 +1,246 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// ── AssistantError ───────────────────────────────────────────────────────
+//
+// An assistant error is persisted as `{name, data}`.
+
+// APIError is the `data` payload of the APIError variant. `responseBody` is
+// searched by substring by the error classifiers, so it is a string kept
+// byte-for-byte, never re-encoded JSON.
+type APIError struct {
+ Message string `json:"message"`
+ StatusCode *uint64 `json:"statusCode,omitempty"`
+ IsRetryable bool `json:"isRetryable"`
+ ResponseHeaders RawObject `json:"responseHeaders,omitempty"`
+ ResponseBody *string `json:"responseBody,omitempty"`
+ Metadata RawObject `json:"metadata,omitempty"`
+}
+
+// UnknownErrorData is the UnknownError payload.
+type UnknownErrorData struct {
+ Message string `json:"message"`
+}
+
+// MessageOutputLengthErrorData is the MessageOutputLengthError payload: no
+// fields.
+type MessageOutputLengthErrorData struct{}
+
+// MessageAbortedErrorData is the MessageAbortedError payload.
+type MessageAbortedErrorData struct {
+ Message string `json:"message"`
+}
+
+// StructuredOutputErrorData is the StructuredOutputError payload.
+type StructuredOutputErrorData struct {
+ Message string `json:"message"`
+ Retries uint64 `json:"retries"`
+}
+
+// ContextOverflowErrorData is the ContextOverflowError payload.
+type ContextOverflowErrorData struct {
+ Message string `json:"message"`
+ ResponseBody *string `json:"responseBody,omitempty"`
+}
+
+// AssistantError is `{name, data}`. `Data` stays raw so an error minted
+// elsewhere round-trips verbatim; the typed constructors below cover the seven
+// known variants.
+type AssistantError struct {
+ Name string `json:"name"`
+ Data json.RawMessage `json:"data"`
+}
+
+func newAssistantError(name string, data any) (AssistantError, error) {
+ raw, err := jsonutil.Marshal(data)
+ if err != nil {
+ return AssistantError{}, err
+ }
+ return AssistantError{Name: name, Data: raw}, nil
+}
+
+func mustAssistantError(name string, data any) AssistantError {
+ e, err := newAssistantError(name, data)
+ if err != nil {
+ panic(fmt.Sprintf("msgmodel: encode %s: %v", name, err))
+ }
+ return e
+}
+
+func NewAPIError(data APIError) AssistantError {
+ return mustAssistantError(ErrNameAPI, data)
+}
+
+func NewUnknownError(message string) AssistantError {
+ return mustAssistantError(ErrNameUnknown, UnknownErrorData{Message: message})
+}
+
+func NewMessageOutputLengthError() AssistantError {
+ return mustAssistantError(ErrNameMessageOutputLength, MessageOutputLengthErrorData{})
+}
+
+func NewMessageAbortedError(message string) AssistantError {
+ return mustAssistantError(ErrNameMessageAborted, MessageAbortedErrorData{Message: message})
+}
+
+func NewStructuredOutputError(message string, retries uint64) AssistantError {
+ return mustAssistantError(ErrNameStructuredOutput, StructuredOutputErrorData{Message: message, Retries: retries})
+}
+
+func NewContextOverflowError(data ContextOverflowErrorData) AssistantError {
+ return mustAssistantError(ErrNameContextOverflow, data)
+}
+
+// IsAborted is a bare `name` comparison, nothing more.
+func (e *AssistantError) IsAborted() bool {
+ return e != nil && e.Name == ErrNameMessageAborted
+}
+
+// ── User ─────────────────────────────────────────
+
+// UserSummary is User.summary.
+type UserSummary struct {
+ Title *string `json:"title,omitempty"`
+ Body *string `json:"body,omitempty"`
+ Diffs []FileDiff `json:"diffs"`
+}
+
+// UserModel is User.model.
+type UserModel struct {
+ ProviderID string `json:"providerID"`
+ ModelID string `json:"modelID"`
+ Variant *string `json:"variant,omitempty"`
+}
+
+type User struct {
+ MessageBase
+ Role string `json:"role"`
+ Time TimeCreated `json:"time"`
+ Format OutputFormat `json:"format,omitempty"`
+ Summary *UserSummary `json:"summary,omitempty"`
+ Agent string `json:"agent"`
+ Model UserModel `json:"model"`
+ System *string `json:"system,omitempty"`
+ Tools *map[string]bool `json:"tools,omitempty"`
+}
+
+func (m User) MessageRole() string { return "user" }
+func (m User) MessageID() string { return m.ID }
+func (m User) MarshalJSON() ([]byte, error) {
+ type alias User
+ m.Role = "user"
+ return tagged(alias(m))
+}
+
+// ── Assistant ────────────────────────────────────
+
+// AssistantTime is Assistant.time.
+type AssistantTime struct {
+ Created uint64 `json:"created"`
+ Completed *uint64 `json:"completed,omitempty"`
+}
+
+// AssistantPath is Assistant.path.
+type AssistantPath struct {
+ Cwd string `json:"cwd"`
+ Root string `json:"root"`
+}
+
+type Assistant struct {
+ MessageBase
+ Role string `json:"role"`
+ Time AssistantTime `json:"time"`
+ Error *AssistantError `json:"error,omitempty"`
+ ParentID string `json:"parentID"`
+ ModelID string `json:"modelID"`
+ ProviderID string `json:"providerID"`
+ // Mode always carries the same value as Agent; both are persisted.
+ Mode string `json:"mode"`
+ Agent string `json:"agent"`
+ Path AssistantPath `json:"path"`
+ Summary *bool `json:"summary,omitempty"`
+ Cost float64 `json:"cost"`
+ Tokens Tokens `json:"tokens"`
+ Structured RawValue `json:"structured,omitempty"`
+ Variant *string `json:"variant,omitempty"`
+ // Finish is one of the unified finish reasons (orclient.Finish*).
+ Finish *string `json:"finish,omitempty"`
+ // Upstream is the endpoint that served the message's last step, copied
+ // from the step-finish part so a message-level consumer (the agent
+ // summary) can attribute cache misses without walking parts.
+ Upstream string `json:"upstream,omitempty"`
+}
+
+func (m Assistant) MessageRole() string { return "assistant" }
+func (m Assistant) MessageID() string { return m.ID }
+func (m Assistant) MarshalJSON() ([]byte, error) {
+ type alias Assistant
+ m.Role = "assistant"
+ return tagged(alias(m))
+}
+
+// ── Info union ───────────────────────────────────────────────────────────
+
+// Info is the User | Assistant union.
+type Info interface {
+ MessageRole() string
+ MessageID() string
+ json.Marshaler
+}
+
+// UnmarshalInfo dispatches on `role`.
+func UnmarshalInfo(raw []byte) (Info, error) {
+ var probe struct {
+ Role string `json:"role"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return nil, err
+ }
+ switch probe.Role {
+ case "user":
+ var m User
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+ case "assistant":
+ var m Assistant
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+ }
+ return nil, fmt.Errorf("msgmodel: unknown message role %q", probe.Role)
+}
+
+// ── WithParts ────────────────────────────────────
+
+type WithParts struct {
+ Info Info `json:"info"`
+ Parts Parts `json:"parts"`
+}
+
+func (w *WithParts) UnmarshalJSON(b []byte) error {
+ var a struct {
+ Info json.RawMessage `json:"info"`
+ Parts Parts `json:"parts"`
+ }
+ if err := json.Unmarshal(b, &a); err != nil {
+ return err
+ }
+ info, err := UnmarshalInfo(a.Info)
+ if err != nil {
+ return err
+ }
+ w.Info = info
+ w.Parts = a.Parts
+ return nil
+}
diff --git a/internal/seniordev/engine/msgmodel/msgmodel.go b/internal/seniordev/engine/msgmodel/msgmodel.go
new file mode 100644
index 0000000000..b373c4db41
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/msgmodel.go
@@ -0,0 +1,172 @@
+//go:build !windows
+
+// Package msgmodel is the persisted message and part model: the assistant,
+// user and tool parts a session stores, the conversion pipeline from stored
+// messages to the model-facing message list, the compaction filter, and the
+// part-assembly helpers the step loop and the stream processor build on.
+//
+// Opaque provider objects (metadata, tool input, structured output) are kept
+// as raw JSON so their bytes round-trip unchanged; the doom-loop guard
+// compares tool inputs byte for byte. Optional fields are pointers with
+// omitempty. Every discriminated union re-asserts its own tag in
+// MarshalJSON, and parts are stored and type-switched as VALUES, never
+// pointers.
+package msgmodel
+
+import (
+ "encoding/json"
+)
+
+// SyntheticAttachmentPrompt opens the synthetic user message that carries
+// media extracted from a tool result.
+const SyntheticAttachmentPrompt = "Attached media from tool result:"
+
+// ── opaque JSON aliases ──────────────────────────────────────────────────
+
+// RawValue is any JSON value carried verbatim. A zero-length RawValue is an
+// absent value (the key is omitted), not JSON null.
+type RawValue = json.RawMessage
+
+// ── discriminant tags ────────────────────────────────────────────────────
+
+// Part `type` discriminants.
+const (
+ PartTypeText = "text"
+ PartTypeReasoning = "reasoning"
+ PartTypeFile = "file"
+ PartTypeTool = "tool"
+ PartTypeStepStart = "step-start"
+ PartTypeStepFinish = "step-finish"
+ PartTypeCompaction = "compaction"
+)
+
+// ToolState `status` discriminants.
+const (
+ ToolStatusPending = "pending"
+ ToolStatusRunning = "running"
+ ToolStatusCompleted = "completed"
+ ToolStatusError = "error"
+)
+
+// AssistantError `name` discriminants.
+const (
+ ErrNameUnknown = "UnknownError"
+ ErrNameMessageOutputLength = "MessageOutputLengthError"
+ ErrNameMessageAborted = "MessageAbortedError"
+ ErrNameStructuredOutput = "StructuredOutputError"
+ ErrNameContextOverflow = "ContextOverflowError"
+ ErrNameAPI = "APIError"
+)
+
+// ── Provider.Model, narrowed ─────────────────────────────────────────────
+
+// ModelAPI is the `api` sub-object of a catalog model: the provider SDK
+// identifier and the provider-side model id. supportsMediaInToolResult reads
+// both.
+type ModelAPI struct {
+ Npm string `json:"npm"`
+ ID string `json:"id"`
+}
+
+// Model is the slice of a catalog model this package reads: `providerID` and
+// `id` feed DifferentModel, `api` feeds supportsMediaInToolResult.
+type Model struct {
+ ProviderID string `json:"providerID"`
+ ID string `json:"id"`
+ API ModelAPI `json:"api"`
+}
+
+// ── output format ──────────────────────────────────
+
+// OutputFormat is the `OutputFormatText | OutputFormatJsonSchema` union. It is
+// only carried, never inspected, by anything in this package, so it keeps its
+// bytes verbatim.
+type OutputFormat = json.RawMessage
+
+// ── shared bases ─────────────────────────────────────────────────────────
+
+// PartBase is embedded first in every part so id/sessionID/messageID lead
+// the JSON.
+type PartBase struct {
+ ID string `json:"id"`
+ SessionID string `json:"sessionID"`
+ MessageID string `json:"messageID"`
+}
+
+// MessageBase is the id pair every message carries.
+type MessageBase struct {
+ ID string `json:"id"`
+ SessionID string `json:"sessionID"`
+}
+
+// ── time sub-structs ─────────────────────────────────────────────────────
+
+// TimeStartEnd is `{start, end?}`. TextPart.time and ReasoningPart.time share
+// the shape; only the outer optionality differs.
+type TimeStartEnd struct {
+ Start uint64 `json:"start"`
+ End *uint64 `json:"end,omitempty"`
+}
+
+// TimeCreated is `{created}`.
+type TimeCreated struct {
+ Created uint64 `json:"created"`
+}
+
+// TokenCache is `{read, write}`.
+type TokenCache struct {
+ Read uint64 `json:"read"`
+ Write uint64 `json:"write"`
+}
+
+// Tokens is the token block shared by StepFinishPart and Assistant.
+type Tokens struct {
+ Total *uint64 `json:"total,omitempty"`
+ Input uint64 `json:"input"`
+ Output uint64 `json:"output"`
+ Reasoning uint64 `json:"reasoning"`
+ Cache TokenCache `json:"cache"`
+}
+
+// ── file part sources ────────────────────────────
+
+// FilePartSourceText is the text span a file part source covers.
+type FilePartSourceText struct {
+ Value string `json:"value"`
+ Start uint64 `json:"start"`
+ End uint64 `json:"end"`
+}
+
+// LSPPosition / LSPRange locate a symbol source in its file.
+type LSPPosition struct {
+ Line uint64 `json:"line"`
+ Character uint64 `json:"character"`
+}
+
+type LSPRange struct {
+ Start LSPPosition `json:"start"`
+ End LSPPosition `json:"end"`
+}
+
+// FilePartSource is the file / symbol / resource source union, discriminated
+// on `type`. Nothing in this package reads it, so it is a single carrier
+// struct rather than an interface, with the shared `text` first.
+type FilePartSource struct {
+ Text FilePartSourceText `json:"text"`
+ Type string `json:"type"`
+ Path string `json:"path,omitempty"`
+ Range *LSPRange `json:"range,omitempty"`
+ Name string `json:"name,omitempty"`
+ Kind *uint64 `json:"kind,omitempty"`
+ ClientName string `json:"clientName,omitempty"`
+ URI string `json:"uri,omitempty"`
+}
+
+// ── FileDiff ─────────────────────────────────────────────────────────────
+
+type FileDiff struct {
+ File string `json:"file"`
+ Patch string `json:"patch"`
+ Additions float64 `json:"additions"`
+ Deletions float64 `json:"deletions"`
+}
diff --git a/internal/seniordev/engine/msgmodel/msgmodel_test.go b/internal/seniordev/engine/msgmodel/msgmodel_test.go
new file mode 100644
index 0000000000..a1198f6083
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/msgmodel_test.go
@@ -0,0 +1,410 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// Tests for the seams and byte-level rules of the message model.
+
+func ptrBool(b bool) *bool { return &b }
+func ptrU64(v uint64) *uint64 { return &v }
+func ptrFloat(f float64) *float64 { return &f }
+func ptrString(s string) *string { return &s }
+func raw(s string) json.RawMessage { return json.RawMessage(s) }
+func rawObj(s string) RawObject { return RawObject(s) }
+func mustJSON(t *testing.T, v any) string {
+ t.Helper()
+ b, err := jsonutil.Marshal(v)
+ if err != nil {
+ t.Fatalf("stringify: %v", err)
+ }
+ return string(b)
+}
+
+// ── DifferentModel ───────────────────────────────────────────────────────
+
+func TestDifferentModel(t *testing.T) {
+ cases := []struct {
+ name string
+ model Model
+ assistant Assistant
+ wantDiffer bool
+ }{
+ {
+ name: "identical",
+ model: Model{ProviderID: "openrouter", ID: "acme/model-pro"},
+ assistant: Assistant{ProviderID: "openrouter", ModelID: "acme/model-pro"},
+ },
+ {
+ name: "different model id",
+ model: Model{ProviderID: "openrouter", ID: "acme/model-max"},
+ assistant: Assistant{ProviderID: "openrouter", ModelID: "acme/model-pro"},
+ wantDiffer: true,
+ },
+ {
+ name: "different provider id",
+ model: Model{ProviderID: "anthropic", ID: "m"},
+ assistant: Assistant{ProviderID: "openrouter", ModelID: "m"},
+ wantDiffer: true,
+ },
+ {
+ // The check is `${a}/${b}` string concatenation, so a slash inside
+ // either half can make two distinct pairs compare EQUAL.
+ name: "slash split ambiguity compares equal",
+ model: Model{ProviderID: "a", ID: "b/c"},
+ assistant: Assistant{ProviderID: "a/b", ModelID: "c"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := DifferentModel(tc.model, tc.assistant); got != tc.wantDiffer {
+ t.Fatalf("DifferentModel = %v, want %v", got, tc.wantDiffer)
+ }
+ })
+ }
+}
+
+// ── TruncateToolOutput ───────────────────────────────────────────────────
+
+func TestTruncateToolOutput(t *testing.T) {
+ if got := TruncateToolOutput("abcdef", nil); got != "abcdef" {
+ t.Fatalf("nil maxChars: %q", got)
+ }
+ if got := TruncateToolOutput("abcdef", ptrFloat(0)); got != "abcdef" {
+ t.Fatalf("falsy 0 maxChars: %q", got)
+ }
+ if got := TruncateToolOutput("abcde", ptrFloat(5)); got != "abcde" {
+ t.Fatalf("exact length: %q", got)
+ }
+ want := "abc\n[Tool output truncated for compaction: omitted 3 chars]"
+ if got := TruncateToolOutput("abcdef", ptrFloat(3)); got != want {
+ t.Fatalf("truncated:\n got %q\nwant %q", got, want)
+ }
+ // The limit counts characters, never bytes, so a multi-byte character is
+ // kept whole.
+ got := TruncateToolOutput("a\U0001F600b", ptrFloat(2))
+ if !strings.HasPrefix(got, "a\U0001F600\n") {
+ t.Fatalf("character cut: %q", got)
+ }
+ if !strings.HasSuffix(got, "omitted 1 chars]") {
+ t.Fatalf("omitted count should count characters: %q", got)
+ }
+}
+
+// ── opaque JSON ──────────────────────────────────────────────────────────
+
+func TestRawObjectPreservesKeyOrderAndEmptyObject(t *testing.T) {
+ part := ToolPart{
+ PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"},
+ CallID: "c",
+ Tool: "bash",
+ State: ToolStateCompleted{
+ Input: rawObj(`{"zulu":1,"alpha":2,"0":3}`),
+ Output: "o",
+ Title: "t",
+ Metadata: nil, // required field: must serialise as {}
+ Time: ToolTimeCompleted{Start: 1, End: 2},
+ },
+ }
+ got := mustJSON(t, part)
+ want := `{"id":"p","sessionID":"s","messageID":"m","type":"tool","callID":"c","tool":"bash",` +
+ `"state":{"status":"completed","input":{"zulu":1,"alpha":2,"0":3},"output":"o","title":"t","metadata":{},"time":{"start":1,"end":2}}}`
+ if got != want {
+ t.Fatalf("\n got %s\nwant %s", got, want)
+ }
+}
+
+func TestOptionalRawObjectIsOmittedWhenAbsentAndKeptWhenEmpty(t *testing.T) {
+ absent := TextPart{PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"}, Text: "x"}
+ if got := mustJSON(t, absent); strings.Contains(got, "metadata") {
+ t.Fatalf("absent metadata should be omitted: %s", got)
+ }
+ empty := absent
+ empty.Metadata = rawObj("{}")
+ if got := mustJSON(t, empty); !strings.Contains(got, `"metadata":{}`) {
+ t.Fatalf("explicit {} metadata should survive: %s", got)
+ }
+}
+
+func TestStringifyDoesNotEscapeHTMLInsideParts(t *testing.T) {
+ part := TextPart{PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"}, Text: "&"}
+ if got := mustJSON(t, part); !strings.Contains(got, `"&"`) {
+ t.Fatalf("HTML should not be escaped: %s", got)
+ }
+}
+
+func TestMarshalForcesTheDiscriminant(t *testing.T) {
+ // A hand-built value with no Type set must still carry its tag.
+ if got := mustJSON(t, StepStartPart{}); !strings.Contains(got, `"type":"step-start"`) {
+ t.Fatalf("step-start tag missing: %s", got)
+ }
+ if got := mustJSON(t, ToolStateError{}); !strings.Contains(got, `"status":"error"`) {
+ t.Fatalf("error status missing: %s", got)
+ }
+ if got := mustJSON(t, Assistant{}); !strings.Contains(got, `"role":"assistant"`) {
+ t.Fatalf("assistant role missing: %s", got)
+ }
+}
+
+// ── providerMeta ─────────────────────────────────────────────────────────
+
+func TestProviderMeta(t *testing.T) {
+ cases := []struct {
+ name string
+ in RawObject
+ want string
+ }{
+ {"absent", nil, ""},
+ {"empty object", rawObj(`{}`), ""},
+ {"only providerExecuted", rawObj(`{"providerExecuted":true}`), ""},
+ {"strips and preserves order", rawObj(`{"zeta":1,"providerExecuted":true,"alpha":2}`), `{"zeta":1,"alpha":2}`},
+ {"nothing to strip", rawObj(`{"a":{"b":[1,2]}}`), `{"a":{"b":[1,2]}}`},
+ {"non-object", rawObj(`"str"`), ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := providerMeta(tc.in)
+ if string(got) != tc.want {
+ t.Fatalf("providerMeta = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// ── doom-loop key ────────────────────────────────────────────────────────
+
+func TestSameInputIsStringifyEqualityNotDeepEquality(t *testing.T) {
+ if !SameInput(rawObj(`{"a":1,"b":2}`), rawObj(`{"a":1, "b":2}`)) {
+ t.Fatal("insignificant whitespace must not matter")
+ }
+ if SameInput(rawObj(`{"a":1,"b":2}`), rawObj(`{"b":2,"a":1}`)) {
+ t.Fatal("key ORDER is load-bearing: the stored bytes differ, so the guard must not fire")
+ }
+ if !SameInput(nil, rawObj(`{}`)) {
+ t.Fatal("absent input reads as {}")
+ }
+}
+
+// ── tool-part settlement ─────────────────────────────────────────────────
+
+func TestPendingToolState(t *testing.T) {
+ got := mustJSON(t, PendingToolState())
+ if got != `{"status":"pending","input":{},"raw":""}` {
+ t.Fatalf("pending literal: %s", got)
+ }
+ if _, ok := PendingToolState().StartTime(); ok {
+ t.Fatal("pending has no time at all")
+ }
+}
+
+func TestSpreadAbortedToolStateCarriesPreviousFields(t *testing.T) {
+ // The spread carries the previous state's fields, so `raw` survives into
+ // an object ToolStateError does not declare.
+ got, err := SpreadAbortedToolState(PendingToolState(), 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{"status":"error","input":{},"raw":"","error":"Tool execution aborted","metadata":{"interrupted":true},"time":{"start":5,"end":5}}`
+ if string(got) != want {
+ t.Fatalf("\n got %s\nwant %s", got, want)
+ }
+
+ running := ToolStateRunning{Input: rawObj(`{}`), Title: ptrString("bash"), Time: ToolTimeStart{Start: 3}}
+ got, err = SpreadAbortedToolState(running, 9)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(got), `"title":"bash"`) {
+ t.Fatalf("running title should carry through the spread: %s", got)
+ }
+ if !strings.Contains(string(got), `"time":{"start":3,"end":9}`) {
+ t.Fatalf("time should be overwritten in place: %s", got)
+ }
+}
+
+func TestSpreadObjectKeyPositions(t *testing.T) {
+ got := SpreadObject(rawObj(`{"a":1,"b":2}`),
+ RawField{Key: "b", Value: raw("9")},
+ RawField{Key: "c", Value: raw("3")},
+ )
+ if string(got) != `{"a":1,"b":9,"c":3}` {
+ t.Fatalf("spread = %s", got)
+ }
+ if string(SpreadObject(nil)) != "{}" {
+ t.Fatal("empty spread should be {}")
+ }
+}
+
+func TestToolPartProviderExecutedIsTruthyNotStrict(t *testing.T) {
+ cases := map[string]bool{
+ `{"providerExecuted":true}`: true,
+ `{"providerExecuted":"yes"}`: true,
+ `{"providerExecuted":1}`: true,
+ `{"providerExecuted":false}`: false,
+ `{"providerExecuted":0}`: false,
+ `{"providerExecuted":""}`: false,
+ `{"providerExecuted":null}`: false,
+ `{}`: false,
+ }
+ for meta, want := range cases {
+ part := ToolPart{Metadata: rawObj(meta)}
+ if got := part.ProviderExecuted(); got != want {
+ t.Fatalf("%s → %v, want %v", meta, got, want)
+ }
+ }
+}
+
+// ── staticToolName ───────────────────────────────────────────────────────
+
+func TestStaticToolNamePreservesInternalDashes(t *testing.T) {
+ cases := map[string]string{
+ "tool-bash": "bash",
+ "tool-multi-word-name": "multi-word-name",
+ "tool-": "",
+ "nodash": "",
+ }
+ for typ, want := range cases {
+ if got := staticToolName(typ); got != want {
+ t.Fatalf("%s → %q, want %q", typ, got, want)
+ }
+ }
+}
+
+// ── the synthetic-message seam ────────────────────────────────────────────
+
+func TestSetMessageIDFactoryForTesting(t *testing.T) {
+ restore := SetMessageIDFactoryForTesting(func() string { return "msg_pinned" })
+ if messageIDAscending() != "msg_pinned" {
+ t.Fatal("factory not installed")
+ }
+ restore()
+ if messageIDAscending() == "msg_pinned" {
+ t.Fatal("restore did not undo the swap")
+ }
+}
+
+// ── the synthetic attachment message ─────────────────────────────────────
+
+func TestSupportsMediaInToolResultByProvider(t *testing.T) {
+ // supportsMediaInToolResult has no @openrouter case, so every media
+ // attachment on the OpenRouter path is extracted into the synthetic user
+ // message rather than staying in the tool result.
+ if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@openrouter/ai-sdk-provider"}}, "image/png") {
+ t.Fatal("openrouter must not support media in tool results")
+ }
+ if !supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/amazon-bedrock"}}, "image/png") {
+ t.Fatal("bedrock supports images")
+ }
+ if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/amazon-bedrock"}}, "application/pdf") {
+ t.Fatal("bedrock does not support pdfs")
+ }
+ // The gemini case lowercases first and requires gemini-3 AND not gemini-2.
+ if !supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/google", ID: "GEMINI-3-PRO"}}, "image/png") {
+ t.Fatal("gemini-3 is case-insensitive")
+ }
+ if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/google", ID: "gemini-3-and-gemini-2"}}, "image/png") {
+ t.Fatal("a gemini-2 substring disqualifies")
+ }
+}
+
+// ── FilterCompacted returns values the caller may mutate ──────────────────
+
+func TestFilterCompactedDoesNotAliasTheInputSlice(t *testing.T) {
+ in := []WithParts{
+ {Info: User{MessageBase: MessageBase{ID: "u2"}}, Parts: Parts{}},
+ {Info: User{MessageBase: MessageBase{ID: "u1"}}, Parts: Parts{}},
+ }
+ out := FilterCompacted(in)
+ if len(out) != 2 || out[0].Info.MessageID() != "u1" || out[1].Info.MessageID() != "u2" {
+ t.Fatalf("expected chronological order, got %v", []string{out[0].Info.MessageID(), out[1].Info.MessageID()})
+ }
+ if in[0].Info.MessageID() != "u2" {
+ t.Fatal("FilterCompacted must not reverse the caller's slice in place")
+ }
+}
+
+// ── ToModelMessages seam smoke test ───────────────────────────────────────
+
+func TestToModelMessagesIsMediaClassification(t *testing.T) {
+ for mime, want := range map[string]bool{
+ "image/png": true,
+ "image/svg+xml": true,
+ "application/pdf": true,
+ "text/plain": false,
+ "application/x-directory": false,
+ } {
+ if got := IsMedia(mime); got != want {
+ t.Fatalf("IsMedia(%q) = %v", mime, got)
+ }
+ }
+}
+
+func TestUnknownUnionTagsAreErrors(t *testing.T) {
+ if _, err := UnmarshalPart([]byte(`{"type":"nope"}`)); err == nil {
+ t.Fatal("expected an error for an unknown part type")
+ }
+ if _, err := UnmarshalToolState([]byte(`{"status":"nope"}`)); err == nil {
+ t.Fatal("expected an error for an unknown tool status")
+ }
+ if _, err := UnmarshalInfo([]byte(`{"role":"tool"}`)); err == nil {
+ t.Fatal("expected an error for an unknown message role")
+ }
+}
+
+func TestAssistantErrorConstructors(t *testing.T) {
+ if got := mustJSON(t, NewMessageAbortedError("stopped")); got != `{"name":"MessageAbortedError","data":{"message":"stopped"}}` {
+ t.Fatalf("aborted: %s", got)
+ }
+ if got := mustJSON(t, NewMessageOutputLengthError()); got != `{"name":"MessageOutputLengthError","data":{}}` {
+ t.Fatalf("output length: %s", got)
+ }
+ api := NewAPIError(APIError{Message: "boom", StatusCode: ptrU64(429), IsRetryable: true, ResponseBody: ptrString(`{"e":1}`)})
+ want := `{"name":"APIError","data":{"message":"boom","statusCode":429,"isRetryable":true,"responseBody":"{\"e\":1}"}}`
+ if got := mustJSON(t, api); got != want {
+ t.Fatalf("api:\n got %s\nwant %s", got, want)
+ }
+ if api.IsAborted() {
+ t.Fatal("APIError must not report as an abort")
+ }
+ aborted := NewMessageAbortedError("x")
+ if !aborted.IsAborted() {
+ t.Fatal("MessageAbortedError must report as an abort")
+ }
+ var nilErr *AssistantError
+ if nilErr.IsAborted() {
+ t.Fatal("nil error is not an abort")
+ }
+}
+
+func TestSummaryAndBoolPointerHelpers(t *testing.T) {
+ if boolValue(nil) || !boolValue(ptrBool(true)) || boolValue(ptrBool(false)) {
+ t.Fatal("boolValue")
+ }
+}
+
+// `upstream` on a step-finish part is present only when the provider reported
+// an endpoint; a record without one marshals without the key.
+func TestStepFinishUpstreamIsOptionalAndRoundTrips(t *testing.T) {
+ const withUpstream = `{"id":"p","sessionID":"s","messageID":"m","type":"step-finish","reason":"stop","cost":0,"tokens":{"input":10,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"upstream":"provider-b"}`
+ part, err := UnmarshalPart([]byte(withUpstream))
+ if err != nil {
+ t.Fatal(err)
+ }
+ finish, ok := part.(StepFinishPart)
+ if !ok || finish.Upstream != "provider-b" {
+ t.Fatalf("decoded part = %#v", part)
+ }
+ if got := mustJSON(t, part); got != withUpstream {
+ t.Fatalf("step-finish with upstream changed shape:\n got %s\nwant %s", got, withUpstream)
+ }
+ finish.Upstream = ""
+ if got := mustJSON(t, finish); strings.Contains(got, "upstream") {
+ t.Fatalf("an unreported upstream must not be serialized: %s", got)
+ }
+}
diff --git a/internal/seniordev/engine/msgmodel/openrouter_inband.go b/internal/seniordev/engine/msgmodel/openrouter_inband.go
new file mode 100644
index 0000000000..8896e31a61
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/openrouter_inband.go
@@ -0,0 +1,58 @@
+//go:build !windows
+
+package msgmodel
+
+import "encoding/json"
+
+// OpenRouter reports some provider failures in-band: a chunk whose `error`
+// field is a bare object like
+//
+// {"code":502,"message":"Network connection lost.",
+// "metadata":{"error_type":"provider_unavailable"}}
+//
+// -- numeric `code`, no {"type":"error"} envelope, no nested `error` object.
+// ParseStreamError cannot see it (it requires the envelope, with a string
+// code), and when the same payload arrives wrapped in a Go error the
+// `value.(error)` branch in FromError returns UnknownError before any parser
+// runs. Either way the classification would not be an APIError, the run's
+// structured classifier could not see a retryable provider failure, and one
+// transient 502 would end the whole run.
+//
+// This recognizer classifies the shape; it deliberately sets no retry policy.
+// StatusCode is carried through so the run layer can apply its bounded policy:
+// transient statuses get a fresh turn while permanent 4xx errors fail fast.
+func openRouterInBandAPIError(raw []byte) *APIError {
+ if len(raw) == 0 || !json.Valid(raw) {
+ return nil
+ }
+ var probe struct {
+ Code *float64 `json:"code"`
+ Message *string `json:"message"`
+ Metadata json.RawMessage `json:"metadata"`
+ // A {"type":...} or nested {"error":...} envelope means this is not
+ // the bare in-band shape; leave those to ParseStreamError.
+ Type *string `json:"type"`
+ Error json.RawMessage `json:"error"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return nil
+ }
+ if probe.Code == nil || probe.Message == nil || probe.Type != nil || len(probe.Error) > 0 {
+ return nil
+ }
+ code := *probe.Code
+ if code != float64(uint64(code)) || code < 100 || code > 599 {
+ return nil
+ }
+ status := uint64(code)
+ body := string(raw)
+ result := &APIError{
+ Message: *probe.Message,
+ StatusCode: &status,
+ ResponseBody: &body,
+ }
+ if len(probe.Metadata) > 0 {
+ result.Metadata = RawObject(probe.Metadata)
+ }
+ return result
+}
diff --git a/internal/seniordev/engine/msgmodel/parts.go b/internal/seniordev/engine/msgmodel/parts.go
new file mode 100644
index 0000000000..b20e0bc93f
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/parts.go
@@ -0,0 +1,243 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// Part is the seven-variant part union, discriminated on `type`. PartBase is
+// embedded first in every variant, so id/sessionID/messageID lead the JSON.
+type Part interface {
+ // PartType is the `type` discriminant.
+ PartType() string
+ // PartBase returns the shared {id, sessionID, messageID}.
+ Base() PartBase
+ json.Marshaler
+}
+
+// tagged re-asserts a union discriminant on marshal, then encodes without
+// HTML escaping so `<`, `>` and `&` inside a part stay readable.
+func tagged(v any) ([]byte, error) { return jsonutil.Marshal(v) }
+
+// ── text ─────────────────────────────────────────
+
+type TextPart struct {
+ PartBase
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Synthetic *bool `json:"synthetic,omitempty"`
+ Ignored *bool `json:"ignored,omitempty"`
+ Time *TimeStartEnd `json:"time,omitempty"`
+ Metadata RawObject `json:"metadata,omitempty"`
+}
+
+func (p TextPart) PartType() string { return PartTypeText }
+func (p TextPart) Base() PartBase { return p.PartBase }
+func (p TextPart) MarshalJSON() ([]byte, error) {
+ type alias TextPart
+ p.Type = PartTypeText
+ return tagged(alias(p))
+}
+
+// ── reasoning ────────────────────────────────────
+//
+// `time` is REQUIRED here, unlike TextPart's optional one.
+
+type ReasoningPart struct {
+ PartBase
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Metadata RawObject `json:"metadata,omitempty"`
+ Time TimeStartEnd `json:"time"`
+}
+
+func (p ReasoningPart) PartType() string { return PartTypeReasoning }
+func (p ReasoningPart) Base() PartBase { return p.PartBase }
+func (p ReasoningPart) MarshalJSON() ([]byte, error) {
+ type alias ReasoningPart
+ p.Type = PartTypeReasoning
+ return tagged(alias(p))
+}
+
+// ── file ─────────────────────────────────────────
+
+type FilePart struct {
+ PartBase
+ Type string `json:"type"`
+ Mime string `json:"mime"`
+ Filename *string `json:"filename,omitempty"`
+ URL string `json:"url"`
+ Source *FilePartSource `json:"source,omitempty"`
+}
+
+func (p FilePart) PartType() string { return PartTypeFile }
+func (p FilePart) Base() PartBase { return p.PartBase }
+func (p FilePart) MarshalJSON() ([]byte, error) {
+ type alias FilePart
+ p.Type = PartTypeFile
+ return tagged(alias(p))
+}
+
+// ── tool ─────────────────────────────────────────
+
+type ToolPart struct {
+ PartBase
+ Type string `json:"type"`
+ CallID string `json:"callID"`
+ Tool string `json:"tool"`
+ State ToolState `json:"state"`
+ Metadata RawObject `json:"metadata,omitempty"`
+}
+
+func (p ToolPart) PartType() string { return PartTypeTool }
+func (p ToolPart) Base() PartBase { return p.PartBase }
+func (p ToolPart) MarshalJSON() ([]byte, error) {
+ type alias ToolPart
+ p.Type = PartTypeTool
+ return tagged(alias(p))
+}
+
+// ProviderExecuted reads the one key of ToolPart.metadata that has a read
+// path. It is a truthiness test, not a strict `true` comparison.
+func (p ToolPart) ProviderExecuted() bool { return p.Metadata.Truthy("providerExecuted") }
+
+// ── step-start ───────────────────────────────────
+
+type StepStartPart struct {
+ PartBase
+ Type string `json:"type"`
+ Snapshot *string `json:"snapshot,omitempty"`
+}
+
+func (p StepStartPart) PartType() string { return PartTypeStepStart }
+func (p StepStartPart) Base() PartBase { return p.PartBase }
+func (p StepStartPart) MarshalJSON() ([]byte, error) {
+ type alias StepStartPart
+ p.Type = PartTypeStepStart
+ return tagged(alias(p))
+}
+
+// ── step-finish ──────────────────────────────────
+
+type StepFinishPart struct {
+ PartBase
+ Type string `json:"type"`
+ Reason string `json:"reason"`
+ Snapshot *string `json:"snapshot,omitempty"`
+ Cost float64 `json:"cost"`
+ Tokens Tokens `json:"tokens"`
+ // Upstream is the endpoint OpenRouter reports as having served the call
+ // (its response `provider` field). Cache-miss attribution needs to know
+ // when successive calls changed endpoint, and the wire already says so.
+ // Absent when the provider never reported one.
+ Upstream string `json:"upstream,omitempty"`
+}
+
+func (p StepFinishPart) PartType() string { return PartTypeStepFinish }
+func (p StepFinishPart) Base() PartBase { return p.PartBase }
+func (p StepFinishPart) MarshalJSON() ([]byte, error) {
+ type alias StepFinishPart
+ p.Type = PartTypeStepFinish
+ return tagged(alias(p))
+}
+
+// ── compaction ───────────────────────────────────
+
+type CompactionPart struct {
+ PartBase
+ Type string `json:"type"`
+ Auto bool `json:"auto"`
+ Overflow *bool `json:"overflow,omitempty"`
+ TailStartID *string `json:"tail_start_id,omitempty"`
+}
+
+func (p CompactionPart) PartType() string { return PartTypeCompaction }
+func (p CompactionPart) Base() PartBase { return p.PartBase }
+func (p CompactionPart) MarshalJSON() ([]byte, error) {
+ type alias CompactionPart
+ p.Type = PartTypeCompaction
+ return tagged(alias(p))
+}
+
+// ── union decode ─────────────────────────────────────────────────────────
+
+// UnmarshalPart dispatches on `type`.
+func UnmarshalPart(raw []byte) (Part, error) {
+ var probe struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return nil, err
+ }
+ var target any
+ switch probe.Type {
+ case PartTypeText:
+ target = new(TextPart)
+ case PartTypeReasoning:
+ target = new(ReasoningPart)
+ case PartTypeFile:
+ target = new(FilePart)
+ case PartTypeTool:
+ target = new(ToolPart)
+ case PartTypeStepStart:
+ target = new(StepStartPart)
+ case PartTypeStepFinish:
+ target = new(StepFinishPart)
+ case PartTypeCompaction:
+ target = new(CompactionPart)
+ default:
+ return nil, fmt.Errorf("msgmodel: unknown part type %q", probe.Type)
+ }
+ if err := json.Unmarshal(raw, target); err != nil {
+ return nil, err
+ }
+ switch p := target.(type) {
+ case *TextPart:
+ return *p, nil
+ case *ReasoningPart:
+ return *p, nil
+ case *FilePart:
+ return *p, nil
+ case *ToolPart:
+ return *p, nil
+ case *StepStartPart:
+ return *p, nil
+ case *StepFinishPart:
+ return *p, nil
+ case *CompactionPart:
+ return *p, nil
+ }
+ return nil, fmt.Errorf("msgmodel: unknown part type %q", probe.Type)
+}
+
+// Parts is `Part[]` with union-aware decoding.
+type Parts []Part
+
+// MarshalJSON keeps a nil slice as `[]`; `parts` is a required array.
+func (ps Parts) MarshalJSON() ([]byte, error) {
+ if ps == nil {
+ return []byte("[]"), nil
+ }
+ return jsonutil.Marshal([]Part(ps))
+}
+
+func (ps *Parts) UnmarshalJSON(b []byte) error {
+ var raws []json.RawMessage
+ if err := json.Unmarshal(b, &raws); err != nil {
+ return err
+ }
+ out := make(Parts, 0, len(raws))
+ for _, raw := range raws {
+ p, err := UnmarshalPart(raw)
+ if err != nil {
+ return err
+ }
+ out = append(out, p)
+ }
+ *ps = out
+ return nil
+}
diff --git a/internal/seniordev/engine/msgmodel/rawobject.go b/internal/seniordev/engine/msgmodel/rawobject.go
new file mode 100644
index 0000000000..fd99a78970
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/rawobject.go
@@ -0,0 +1,260 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "strconv"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// RawObject is a JSON object kept as the verbatim bytes it arrived as. Never
+// decode one into map[string]any: Go sorts map keys on re-marshal, and the
+// doom-loop guard compares tool inputs byte for byte, key order included.
+//
+// A zero-length RawObject is an absent value. On an OPTIONAL field (tagged
+// `omitempty`) that means the key is omitted; on a REQUIRED field it marshals
+// as `{}`, which is what the processor writes for an empty input/metadata.
+type RawObject json.RawMessage
+
+func (r RawObject) MarshalJSON() ([]byte, error) {
+ if len(r) == 0 {
+ return []byte("{}"), nil
+ }
+ return []byte(r), nil
+}
+
+func (r *RawObject) UnmarshalJSON(b []byte) error {
+ *r = RawObject(append([]byte(nil), b...))
+ return nil
+}
+
+// Raw returns the underlying bytes, or nil when the value was absent: the
+// reading for an OPTIONAL field.
+func (r RawObject) Raw() json.RawMessage {
+ if len(r) == 0 {
+ return nil
+ }
+ return json.RawMessage(r)
+}
+
+// Value returns `{}` for an absent value: the reading for a REQUIRED field
+// (`input`, ToolStateCompleted.metadata), which is always at least an empty
+// object.
+func (r RawObject) Value() json.RawMessage {
+ if len(r) == 0 {
+ return json.RawMessage("{}")
+ }
+ return json.RawMessage(r)
+}
+
+func trimSpace(b []byte) []byte { return bytes.TrimSpace(b) }
+
+// arrayElements walks a JSON array at the token level, keeping each element's
+// bytes verbatim. ok=false when the value is not an array.
+func arrayElements(raw []byte) ([]json.RawMessage, bool) {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ return nil, false
+ }
+ var out []json.RawMessage
+ if err := json.Unmarshal(trimmed, &out); err != nil {
+ return nil, false
+ }
+ return out, true
+}
+
+// RawField is one own property of a JSON object, in source order.
+type RawField struct {
+ Key string
+ Value json.RawMessage
+}
+
+// objectFields walks a JSON object at the token level so key ORDER survives.
+// Returns ok=false when the value is not an object; callers treat that as
+// "absent".
+func objectFields(raw []byte) ([]RawField, bool) {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || trimmed[0] != '{' {
+ return nil, false
+ }
+ dec := json.NewDecoder(bytes.NewReader(trimmed))
+ dec.UseNumber()
+ tok, err := dec.Token()
+ if err != nil {
+ return nil, false
+ }
+ if delim, ok := tok.(json.Delim); !ok || delim != '{' {
+ return nil, false
+ }
+ var out []RawField
+ for dec.More() {
+ keyTok, err := dec.Token()
+ if err != nil {
+ return nil, false
+ }
+ key, ok := keyTok.(string)
+ if !ok {
+ return nil, false
+ }
+ var value json.RawMessage
+ if err := dec.Decode(&value); err != nil {
+ return nil, false
+ }
+ out = append(out, RawField{Key: key, Value: value})
+ }
+ if _, err := dec.Token(); err != nil {
+ return nil, false
+ }
+ // Reject trailing garbage.
+ if _, err := dec.Token(); err != io.EOF {
+ return nil, false
+ }
+ return out, true
+}
+
+// Fields returns the object's own properties in insertion order, or nil when
+// the value is absent or not an object.
+func (r RawObject) Fields() []RawField {
+ fields, ok := objectFields(r)
+ if !ok {
+ return nil
+ }
+ return fields
+}
+
+// Field returns the raw value at key, or ok=false when absent (or when the
+// receiver is not an object).
+func (r RawObject) Field(key string) (json.RawMessage, bool) {
+ var (
+ value json.RawMessage
+ found bool
+ )
+ // A duplicate key in the source text means the LAST one wins, so scan to
+ // the end.
+ for _, f := range r.Fields() {
+ if f.Key == key {
+ value, found = f.Value, true
+ }
+ }
+ return value, found
+}
+
+// Truthy applies truthyJSON to the value at key; an absent key is false.
+func (r RawObject) Truthy(key string) bool {
+ value, ok := r.Field(key)
+ if !ok {
+ return false
+ }
+ return truthyJSON(value)
+}
+
+// StrictTrue reports whether the value at key is exactly `true`.
+func (r RawObject) StrictTrue(key string) bool {
+ value, ok := r.Field(key)
+ if !ok {
+ return false
+ }
+ return string(bytes.TrimSpace(value)) == "true"
+}
+
+// StringField returns the value at key when it is a string.
+func (r RawObject) StringField(key string) (string, bool) {
+ value, ok := r.Field(key)
+ if !ok {
+ return "", false
+ }
+ var s string
+ if err := json.Unmarshal(value, &s); err != nil {
+ return "", false
+ }
+ return s, true
+}
+
+// truthyJSON is the truthiness rule for a JSON value: objects and arrays are
+// always truthy; "" / 0 / -0 / false / null are not.
+func truthyJSON(raw []byte) bool {
+ trimmed := bytes.TrimSpace(raw)
+ switch {
+ case len(trimmed) == 0:
+ return false
+ case string(trimmed) == "null", string(trimmed) == "false":
+ return false
+ case string(trimmed) == "true":
+ return true
+ case trimmed[0] == '"':
+ var s string
+ if err := json.Unmarshal(trimmed, &s); err != nil {
+ return false
+ }
+ return s != ""
+ case trimmed[0] == '{' || trimmed[0] == '[':
+ return true
+ }
+ f, err := strconv.ParseFloat(string(trimmed), 64)
+ return err == nil && f != 0
+}
+
+// providerMeta is the tool metadata minus its providerExecuted key, or nil
+// when nothing else is there. The surviving keys keep their original order,
+// so this rebuilds the object from the token walk instead of decoding into a
+// map.
+func providerMeta(metadata RawObject) json.RawMessage {
+ if len(metadata) == 0 {
+ return nil
+ }
+ fields, ok := objectFields(metadata)
+ if !ok {
+ // Non-object metadata has no keys to keep.
+ return nil
+ }
+ kept := make([]RawField, 0, len(fields))
+ for _, f := range fields {
+ if f.Key == "providerExecuted" {
+ continue
+ }
+ kept = append(kept, f)
+ }
+ if len(kept) == 0 {
+ return nil
+ }
+ var buf bytes.Buffer
+ buf.WriteByte('{')
+ for i, f := range kept {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ key, err := jsonutil.Marshal(f.Key)
+ if err != nil {
+ return nil
+ }
+ buf.Write(key)
+ buf.WriteByte(':')
+ buf.Write(f.Value)
+ }
+ buf.WriteByte('}')
+ return json.RawMessage(buf.Bytes())
+}
+
+// SameInput is the doom-loop equality test: two tool inputs compared as
+// stored bytes. Both sides are already verbatim, so this only has to
+// normalise insignificant whitespace; key order is deliberately NOT
+// normalised.
+func SameInput(a, b RawObject) bool {
+ return bytes.Equal(compactRaw(a), compactRaw(b))
+}
+
+func compactRaw(r RawObject) []byte {
+ raw, err := r.MarshalJSON()
+ if err != nil {
+ return nil
+ }
+ var buf bytes.Buffer
+ if err := json.Compact(&buf, raw); err != nil {
+ return raw
+ }
+ return buf.Bytes()
+}
diff --git a/internal/seniordev/engine/msgmodel/settle.go b/internal/seniordev/engine/msgmodel/settle.go
new file mode 100644
index 0000000000..3689b18f3f
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/settle.go
@@ -0,0 +1,124 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "bytes"
+ "encoding/json"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// Tool-part assembly for the stream processor's settlement path. Everything
+// here is pure: the in-flight tool-call registry belongs to the step loop's
+// processor.
+
+// Fixed strings the settlement path writes. ToolAbortedError is replayed to
+// the model as the tool's errorText; ToolInterruptedError is what a pending or
+// running tool replays as.
+const (
+ ToolAbortedError = "Tool execution aborted"
+ ToolInterruptedError = "[Tool execution was interrupted]"
+ ToolCompactedOutput = "[Old tool result content cleared]"
+)
+
+// PendingToolState is `{status:"pending", input:{}, raw:""}`.
+func PendingToolState() ToolStatePending {
+ return ToolStatePending{Status: ToolStatusPending, Input: RawObject("{}"), Raw: ""}
+}
+
+// CompletedToolState is the typed `completed` state. The processor writes its
+// keys in the order status, input, output, metadata, title, time, attachments;
+// this struct keeps the declared order (title before metadata).
+func CompletedToolState(input RawObject, output, title string, metadata RawObject, start, end uint64, attachments *[]FilePart) ToolStateCompleted {
+ return ToolStateCompleted{
+ Status: ToolStatusCompleted,
+ Input: input,
+ Output: output,
+ Title: title,
+ Metadata: metadata,
+ Time: ToolTimeCompleted{Start: start, End: end},
+ Attachments: attachments,
+ }
+}
+
+// SpreadAbortedToolState is the cleanup drain's force-write: `status:"error"`,
+// `error:"Tool execution aborted"`, `metadata:{...existing, interrupted:true}`
+// and `time.start` taken from the previous state, or `now` when the state has
+// none (`pending` does not). It is a spread over the previous state,
+// `{...state, status, error, metadata, time}`, so a `pending` state's
+// `raw` and a `running` state's `title` survive into an object ToolStateError
+// does not declare. Returned as raw JSON because those fields have no typed
+// home.
+func SpreadAbortedToolState(prev ToolState, now uint64) (json.RawMessage, error) {
+ start := now
+ if s, ok := prev.StartTime(); ok {
+ start = s
+ }
+ var existing RawObject
+ if prev != nil && IsRecord(prev.ToolMetadata()) {
+ existing = prev.ToolMetadata()
+ }
+ base, err := stateObject(prev)
+ if err != nil {
+ return nil, err
+ }
+ timeRaw, err := jsonutil.Marshal(ToolTimeSpan{Start: start, End: now})
+ if err != nil {
+ return nil, err
+ }
+ return SpreadObject(base,
+ RawField{Key: "status", Value: jsonString(ToolStatusError)},
+ RawField{Key: "error", Value: jsonString(ToolAbortedError)},
+ RawField{Key: "metadata", Value: json.RawMessage(MergeInterrupted(existing))},
+ RawField{Key: "time", Value: timeRaw},
+ ), nil
+}
+
+// SpreadToolState is `{...prev, ...overrides}` for any transition: the
+// tool-call → running step as well as the cleanup drain.
+func SpreadToolState(prev ToolState, overrides ...RawField) (json.RawMessage, error) {
+ base, err := stateObject(prev)
+ if err != nil {
+ return nil, err
+ }
+ return SpreadObject(base, overrides...), nil
+}
+
+func stateObject(prev ToolState) (RawObject, error) {
+ if prev == nil {
+ return nil, nil
+ }
+ raw, err := jsonutil.Marshal(prev)
+ if err != nil {
+ return nil, err
+ }
+ return RawObject(raw), nil
+}
+
+// MergeInterrupted is `{...metadata, interrupted: true}`. A pre-existing
+// `interrupted` key keeps its original position.
+func MergeInterrupted(metadata RawObject) RawObject {
+ return RawObject(SpreadObject(metadata, RawField{Key: "interrupted", Value: json.RawMessage("true")}))
+}
+
+// SpreadObject is the object spread `{...base, k1: v1, k2: v2}`: an
+// overridden key keeps the position it had in base and takes the new value; a
+// new key is appended in the order given. Key order is load-bearing: the
+// doom-loop guard compares the stored bytes verbatim.
+func SpreadObject(base RawObject, overrides ...RawField) json.RawMessage {
+ fields := append([]RawField(nil), base.Fields()...)
+ for _, o := range overrides {
+ fields = upsertField(fields, o)
+ }
+ if len(fields) == 0 {
+ return json.RawMessage("{}")
+ }
+ return json.RawMessage(encodeFields(fields))
+}
+
+// IsRecord reports a JSON object: neither null nor an array.
+func IsRecord(v RawObject) bool {
+ t := bytes.TrimSpace(v)
+ return len(t) > 0 && t[0] == '{'
+}
diff --git a/internal/seniordev/engine/msgmodel/storage.go b/internal/seniordev/engine/msgmodel/storage.go
new file mode 100644
index 0000000000..9a25c34da5
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/storage.go
@@ -0,0 +1,160 @@
+//go:build !windows
+
+// Storage functions. Query construction is behind Store; Page does the
+// descending (time,id) pagination with a one-row lookahead, hydration, and
+// per-page reversal. Stream returns newest-first order.
+package msgmodel
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+var ErrNotFound = errors.New("msgmodel: not found")
+
+type MessageRecord struct {
+ Info Info
+ TimeCreated float64
+}
+
+// Store methods must return messages in descending (time_created,id) order
+// and parts in ascending (message_id,id) order.
+type Store interface {
+ SelectMessages(ctx context.Context, sessionID string, limit int, before *Cursor) ([]MessageRecord, error)
+ SessionExists(ctx context.Context, sessionID string) (bool, error)
+ SelectParts(ctx context.Context, messageIDs []string) (Parts, error)
+ SelectMessage(ctx context.Context, sessionID, messageID string) (MessageRecord, bool, error)
+ SelectMessageParts(ctx context.Context, messageID string) (Parts, error)
+}
+
+type PageInput struct {
+ SessionID string
+ Limit int
+ Before *string
+}
+
+type PageResult struct {
+ Items []WithParts `json:"items"`
+ More bool `json:"more"`
+ Cursor *string `json:"cursor,omitempty"`
+}
+
+func Page(ctx context.Context, store Store, input PageInput) (PageResult, error) {
+ var before *Cursor
+ if input.Before != nil && *input.Before != "" {
+ decoded, err := DecodeCursor(*input.Before)
+ if err != nil {
+ return PageResult{}, err
+ }
+ before = &decoded
+ }
+ rows, err := store.SelectMessages(ctx, input.SessionID, input.Limit+1, before)
+ if err != nil {
+ return PageResult{}, err
+ }
+ if len(rows) == 0 {
+ ok, err := store.SessionExists(ctx, input.SessionID)
+ if err != nil {
+ return PageResult{}, err
+ }
+ if !ok {
+ return PageResult{}, fmt.Errorf("%w: Session not found: %s", ErrNotFound, input.SessionID)
+ }
+ return PageResult{Items: []WithParts{}, More: false}, nil
+ }
+
+ more := len(rows) > input.Limit
+ slice := rows
+ if more {
+ slice = rows[:input.Limit]
+ }
+ items, err := hydrateRecords(ctx, store, slice)
+ if err != nil {
+ return PageResult{}, err
+ }
+ reverseWithParts(items)
+ result := PageResult{Items: items, More: more}
+ if more && len(slice) > 0 {
+ tail := slice[len(slice)-1]
+ encoded, err := EncodeCursor(Cursor{ID: tail.Info.MessageID(), Time: float64(tail.TimeCreated)})
+ if err != nil {
+ return PageResult{}, err
+ }
+ result.Cursor = &encoded
+ }
+ return result, nil
+}
+
+func Stream(ctx context.Context, store Store, sessionID string) ([]WithParts, error) {
+ const size = 50
+ var before *string
+ result := []WithParts{}
+ for {
+ next, err := Page(ctx, store, PageInput{SessionID: sessionID, Limit: size, Before: before})
+ if err != nil {
+ return nil, err
+ }
+ if len(next.Items) == 0 {
+ break
+ }
+ for i := len(next.Items) - 1; i >= 0; i-- {
+ result = append(result, next.Items[i])
+ }
+ if !next.More || next.Cursor == nil {
+ break
+ }
+ before = next.Cursor
+ }
+ return result, nil
+}
+
+func MessageParts(ctx context.Context, store Store, messageID string) (Parts, error) {
+ return store.SelectMessageParts(ctx, messageID)
+}
+
+func Get(ctx context.Context, store Store, sessionID, messageID string) (WithParts, error) {
+ row, ok, err := store.SelectMessage(ctx, sessionID, messageID)
+ if err != nil {
+ return WithParts{}, err
+ }
+ if !ok {
+ return WithParts{}, fmt.Errorf("%w: Message not found: %s", ErrNotFound, messageID)
+ }
+ parts, err := store.SelectMessageParts(ctx, messageID)
+ if err != nil {
+ return WithParts{}, err
+ }
+ return WithParts{Info: row.Info, Parts: nonnilParts(parts)}, nil
+}
+
+func hydrateRecords(ctx context.Context, store Store, rows []MessageRecord) ([]WithParts, error) {
+ ids := make([]string, 0, len(rows))
+ for _, row := range rows {
+ ids = append(ids, row.Info.MessageID())
+ }
+ parts, err := store.SelectParts(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ byMessage := make(map[string]Parts, len(ids))
+ for _, part := range parts {
+ base := part.Base()
+ byMessage[base.MessageID] = append(byMessage[base.MessageID], part)
+ }
+ out := make([]WithParts, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, WithParts{
+ Info: row.Info,
+ Parts: nonnilParts(byMessage[row.Info.MessageID()]),
+ })
+ }
+ return out, nil
+}
+
+func nonnilParts(parts Parts) Parts {
+ if parts == nil {
+ return Parts{}
+ }
+ return parts
+}
diff --git a/internal/seniordev/engine/msgmodel/storage_test.go b/internal/seniordev/engine/msgmodel/storage_test.go
new file mode 100644
index 0000000000..6fc8933fe5
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/storage_test.go
@@ -0,0 +1,199 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "testing"
+)
+
+type memoryMessageStore struct {
+ sessions map[string]bool
+ records []MessageRecord
+ parts Parts
+}
+
+func (s *memoryMessageStore) SelectMessages(
+ _ context.Context, sessionID string, limit int, before *Cursor,
+) ([]MessageRecord, error) {
+ rows := []MessageRecord{}
+ for _, row := range s.records {
+ if infoSessionID(row.Info) != sessionID {
+ continue
+ }
+ if before != nil {
+ time := row.TimeCreated
+ if !(time < float64(before.Time) ||
+ (time == float64(before.Time) && row.Info.MessageID() < before.ID)) {
+ continue
+ }
+ }
+ rows = append(rows, row)
+ }
+ sort.SliceStable(rows, func(i, j int) bool {
+ if rows[i].TimeCreated != rows[j].TimeCreated {
+ return rows[i].TimeCreated > rows[j].TimeCreated
+ }
+ return rows[i].Info.MessageID() > rows[j].Info.MessageID()
+ })
+ if limit < len(rows) {
+ rows = rows[:limit]
+ }
+ return rows, nil
+}
+
+func (s *memoryMessageStore) SessionExists(_ context.Context, sessionID string) (bool, error) {
+ return s.sessions[sessionID], nil
+}
+
+func (s *memoryMessageStore) SelectParts(_ context.Context, messageIDs []string) (Parts, error) {
+ wanted := map[string]bool{}
+ for _, id := range messageIDs {
+ wanted[id] = true
+ }
+ out := Parts{}
+ for _, part := range s.parts {
+ if wanted[part.Base().MessageID] {
+ out = append(out, part)
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ a, b := out[i].Base(), out[j].Base()
+ if a.MessageID != b.MessageID {
+ return a.MessageID < b.MessageID
+ }
+ return a.ID < b.ID
+ })
+ return out, nil
+}
+
+func (s *memoryMessageStore) SelectMessage(
+ _ context.Context, sessionID, messageID string,
+) (MessageRecord, bool, error) {
+ for _, row := range s.records {
+ if row.Info.MessageID() == messageID && infoSessionID(row.Info) == sessionID {
+ return row, true, nil
+ }
+ }
+ return MessageRecord{}, false, nil
+}
+
+func (s *memoryMessageStore) SelectMessageParts(_ context.Context, messageID string) (Parts, error) {
+ return s.SelectParts(context.Background(), []string{messageID})
+}
+
+func infoSessionID(info Info) string {
+ switch value := info.(type) {
+ case User:
+ return value.SessionID
+ case Assistant:
+ return value.SessionID
+ default:
+ return ""
+ }
+}
+
+func storedUser(id string, created float64) MessageRecord {
+ return MessageRecord{
+ Info: User{
+ MessageBase: MessageBase{ID: id, SessionID: "ses_1"},
+ Time: TimeCreated{Created: uint64(created)},
+ Agent: "build",
+ Model: UserModel{ProviderID: "openrouter", ModelID: "m"},
+ },
+ TimeCreated: created,
+ }
+}
+
+func TestPageHydratesAndPaginatesByTimeThenID(t *testing.T) {
+ store := &memoryMessageStore{
+ sessions: map[string]bool{"ses_1": true},
+ records: []MessageRecord{
+ storedUser("m1", 1), storedUser("m4", 2),
+ storedUser("m2", 1), storedUser("m3", 2),
+ },
+ parts: Parts{
+ TextPart{PartBase: PartBase{ID: "p4b", SessionID: "ses_1", MessageID: "m4"}, Text: "b"},
+ TextPart{PartBase: PartBase{ID: "p3", SessionID: "ses_1", MessageID: "m3"}, Text: "three"},
+ TextPart{PartBase: PartBase{ID: "p4a", SessionID: "ses_1", MessageID: "m4"}, Text: "a"},
+ },
+ }
+ first, err := Page(context.Background(), store, PageInput{SessionID: "ses_1", Limit: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := messageIDs(first.Items); fmt.Sprint(got) != "[m3 m4]" {
+ t.Fatalf("first page order = %v", got)
+ }
+ if !first.More || first.Cursor == nil {
+ t.Fatalf("first page cursor/more = %#v", first)
+ }
+ if len(first.Items[0].Parts) != 1 || len(first.Items[1].Parts) != 2 {
+ t.Fatalf("hydrated parts = %#v", first.Items)
+ }
+ if first.Items[1].Parts[0].Base().ID != "p4a" {
+ t.Fatalf("parts not in id order: %#v", first.Items[1].Parts)
+ }
+
+ second, err := Page(context.Background(), store, PageInput{
+ SessionID: "ses_1", Limit: 2, Before: first.Cursor,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := messageIDs(second.Items); fmt.Sprint(got) != "[m1 m2]" {
+ t.Fatalf("second page order = %v", got)
+ }
+ if second.More || second.Cursor != nil {
+ t.Fatalf("unexpected second-page continuation: %#v", second)
+ }
+}
+
+func TestStreamKeepsGeneratorNewestFirstAcrossPages(t *testing.T) {
+ store := &memoryMessageStore{sessions: map[string]bool{"ses_1": true}}
+ for i := 1; i <= 53; i++ {
+ store.records = append(store.records, storedUser(fmt.Sprintf("m%03d", i), float64(i)))
+ }
+ got, err := Stream(context.Background(), store, "ses_1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 53 || got[0].Info.MessageID() != "m053" || got[52].Info.MessageID() != "m001" {
+ t.Fatalf("stream order/length: %d %s..%s", len(got), got[0].Info.MessageID(), got[len(got)-1].Info.MessageID())
+ }
+}
+
+func TestPageAndGetNotFoundMessages(t *testing.T) {
+ store := &memoryMessageStore{sessions: map[string]bool{"ses_1": true}}
+ empty, err := Page(context.Background(), store, PageInput{SessionID: "ses_1", Limit: 5})
+ if err != nil || empty.More || len(empty.Items) != 0 || empty.Items == nil {
+ t.Fatalf("existing empty session = %#v, %v", empty, err)
+ }
+ _, err = Page(context.Background(), store, PageInput{SessionID: "missing", Limit: 5})
+ if !errors.Is(err, ErrNotFound) || err.Error() != "msgmodel: not found: Session not found: missing" {
+ t.Fatalf("page missing error = %v", err)
+ }
+ _, err = Get(context.Background(), store, "ses_1", "missing")
+ if !errors.Is(err, ErrNotFound) || err.Error() != "msgmodel: not found: Message not found: missing" {
+ t.Fatalf("get missing error = %v", err)
+ }
+}
+
+func messageIDs(items []WithParts) []string {
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ out = append(out, item.Info.MessageID())
+ }
+ return out
+}
+
+func TestDecodeCursorRejectsInvalidPayloads(t *testing.T) {
+ for _, input := range []string{"***", "bnVsbA", "eyJpZCI6Im0iLCJ0aW1lIjotMX0"} {
+ if _, err := DecodeCursor(input); err == nil {
+ t.Errorf("DecodeCursor(%q) unexpectedly succeeded", input)
+ }
+ }
+}
diff --git a/internal/seniordev/engine/msgmodel/tomodelmessages.go b/internal/seniordev/engine/msgmodel/tomodelmessages.go
new file mode 100644
index 0000000000..bb6005e92c
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/tomodelmessages.go
@@ -0,0 +1,485 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "strconv"
+ "strings"
+ "sync/atomic"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil"
+)
+
+// ToModelOptions tune the conversion. Both are pointers: an absent StripMedia
+// is false, and an absent or non-positive ToolOutputMaxChars leaves outputs
+// untruncated.
+type ToModelOptions struct {
+ StripMedia *bool
+ ToolOutputMaxChars *float64
+}
+
+func (o *ToModelOptions) stripMedia() bool {
+ return o != nil && o.StripMedia != nil && *o.StripMedia
+}
+
+func (o *ToModelOptions) toolOutputMaxChars() *float64 {
+ if o == nil {
+ return nil
+ }
+ return o.ToolOutputMaxChars
+}
+
+// messageIDAscending mints the id of the synthetic "Attached media from tool
+// result:" UIMessage. ConvertToModelMessages drops that id, so nothing
+// observable depends on the value; the default is a package-local counter.
+var messageIDAscending = defaultMessageIDAscending
+
+var syntheticMessageCounter atomic.Uint64
+
+func defaultMessageIDAscending() string {
+ return "msg_synthetic_" + strconv.FormatFloat(float64(syntheticMessageCounter.Add(1)), 'f', -1, 64)
+}
+
+// SetMessageIDFactoryForTesting swaps the synthetic-message id source and
+// returns a restore func.
+func SetMessageIDFactoryForTesting(f func() string) func() {
+ prev := messageIDAscending
+ messageIDAscending = f
+ return func() { messageIDAscending = prev }
+}
+
+// IsMedia reports whether a mime type is an image or a PDF.
+func IsMedia(mime string) bool {
+ return strings.HasPrefix(mime, "image/") || mime == "application/pdf"
+}
+
+// DifferentModel is a plain string comparison of "/"
+// between the model about to be called and the model that produced the
+// historical turn. When they differ, that turn's provider-specific metadata
+// is stripped below.
+func DifferentModel(model Model, msg Assistant) bool {
+ return model.ProviderID+"/"+model.ID != msg.ProviderID+"/"+msg.ModelID
+}
+
+// supportsMediaInToolResult reports whether the provider SDK accepts media
+// inside a tool result. No case matches the OpenRouter provider, so on that
+// path tool-result media always reaches the model through the synthetic
+// attachment message instead.
+func supportsMediaInToolResult(model Model, mime string) bool {
+ switch model.API.Npm {
+ case "@ai-sdk/anthropic":
+ return true
+ case "@ai-sdk/openai":
+ return true
+ case "@ai-sdk/amazon-bedrock":
+ return strings.HasPrefix(mime, "image/")
+ case "@ai-sdk/google-vertex/anthropic":
+ return true
+ case "@ai-sdk/google":
+ id := strings.ToLower(model.API.ID)
+ return strings.Contains(id, "gemini-3") && !strings.Contains(id, "gemini-2")
+ }
+ return false
+}
+
+// TruncateToolOutput keeps the first maxChars characters of a tool output and
+// appends a marker naming how many were dropped. A nil or non-positive limit
+// leaves the text alone.
+func TruncateToolOutput(text string, maxChars *float64) string {
+ if maxChars == nil || !(*maxChars > 0) {
+ return text
+ }
+ limit := int(*maxChars)
+ runes := []rune(text)
+ if len(runes) <= limit {
+ return text
+ }
+ omitted := len(runes) - limit
+ return string(runes[:limit]) + "\n[Tool output truncated for compaction: omitted " + strconv.Itoa(omitted) + " chars]"
+}
+
+// toModelOutput is the output converter handed to ConvertToModelMessages for
+// every tool name seen, regardless of that tool's state.
+func toModelOutput(_ string, _ RawValue, output RawValue) ToolOutput {
+ if s, ok := asJSONString(output); ok {
+ return ToolOutput{Type: "text", Value: s}
+ }
+ // Object-ish covers arrays and null too, but senior-dev only ever hands it
+ // the {text, attachments} shape or a string.
+ if isJSONObjectish(output) {
+ obj := RawObject(output)
+ text, _ := obj.StringField("text")
+ value := []any{}
+ if text != "" {
+ value = append(value, ToolOutputContentText{Type: "text", Text: text})
+ }
+ for _, att := range attachmentList(obj) {
+ url, _ := att.StringField("url")
+ if !strings.HasPrefix(url, "data:") || !strings.Contains(url, ",") {
+ continue
+ }
+ mime, _ := att.StringField("mime")
+ value = append(value, ToolOutputContentMedia{
+ Type: "media",
+ MediaType: mime,
+ Data: afterFirstComma(url),
+ })
+ }
+ return ToolOutput{Type: "content", Value: value}
+ }
+ return ToolOutput{Type: "json", Value: toJSONValue(output)}
+}
+
+// afterFirstComma is the payload of a data: URL; the whole url when there is
+// no comma at all.
+func afterFirstComma(url string) string {
+ i := strings.Index(url, ",")
+ if i == -1 {
+ return url
+ }
+ return url[i+1:]
+}
+
+// ToModelMessages converts stored messages into the model-facing message
+// list.
+func ToModelMessages(input []WithParts, model Model, options *ToModelOptions) ([]ModelMessage, error) {
+ result := []UIMessage{}
+ // Tool names in first-seen order, deduplicated.
+ var toolNames []string
+ seenTool := map[string]bool{}
+
+ for _, msg := range input {
+ if len(msg.Parts) == 0 {
+ continue
+ }
+
+ if user, ok := msg.Info.(User); ok {
+ userMessage := UIMessage{ID: user.ID, Role: "user", Parts: []UIPart{}}
+ for _, raw := range msg.Parts {
+ // The three checks below are independent, not an else-if chain.
+ if part, ok := raw.(TextPart); ok {
+ if !boolValue(part.Ignored) && part.Text != "" {
+ userMessage.Parts = append(userMessage.Parts, UIPart{Type: "text", Text: part.Text})
+ }
+ }
+ if part, ok := raw.(FilePart); ok {
+ if part.Mime != "text/plain" && part.Mime != "application/x-directory" {
+ if options.stripMedia() && IsMedia(part.Mime) {
+ name := "file"
+ if part.Filename != nil {
+ name = *part.Filename
+ }
+ userMessage.Parts = append(userMessage.Parts, UIPart{
+ Type: "text",
+ Text: "[Attached " + part.Mime + ": " + name + "]",
+ })
+ } else {
+ userMessage.Parts = append(userMessage.Parts, UIPart{
+ Type: "file",
+ URL: part.URL,
+ MediaType: part.Mime,
+ Filename: optionalStringValue(part.Filename),
+ })
+ }
+ }
+ }
+ if _, ok := raw.(CompactionPart); ok {
+ userMessage.Parts = append(userMessage.Parts, UIPart{Type: "text", Text: "What did we do so far?"})
+ }
+ }
+ if len(userMessage.Parts) > 0 {
+ result = append(result, userMessage)
+ }
+ }
+
+ assistant, isAssistant := msg.Info.(Assistant)
+ if !isAssistant {
+ continue
+ }
+
+ differentModel := DifferentModel(model, assistant)
+ var media []mediaAttachment
+
+ // Drop the whole message on any error UNLESS it is a
+ // MessageAbortedError and at least one part is neither step-start nor
+ // reasoning.
+ if assistant.Error != nil {
+ hasSubstantivePart := false
+ for _, raw := range msg.Parts {
+ if raw.PartType() != PartTypeStepStart && raw.PartType() != PartTypeReasoning {
+ hasSubstantivePart = true
+ break
+ }
+ }
+ if !(assistant.Error.IsAborted() && hasSubstantivePart) {
+ continue
+ }
+ }
+
+ assistantMessage := UIMessage{ID: assistant.ID, Role: "assistant", Parts: []UIPart{}}
+
+ // Anthropic adaptive thinking can persist an empty text
+ // part as a structural separator between signed reasoning blocks;
+ // replay it as a single space so it survives the SDK's empty-text
+ // filter.
+ hasSignedReasoning := false
+ for _, raw := range msg.Parts {
+ part, ok := raw.(ReasoningPart)
+ if !ok {
+ continue
+ }
+ anthropic, ok := part.Metadata.Field("anthropic")
+ if !ok {
+ continue
+ }
+ if signature, ok := RawObject(anthropic).Field("signature"); ok && !isJSONNull(signature) {
+ hasSignedReasoning = true
+ break
+ }
+ }
+
+ for _, raw := range msg.Parts {
+ switch part := raw.(type) {
+ case TextPart:
+ text := part.Text
+ if text == "" && hasSignedReasoning {
+ text = " "
+ }
+ ui := UIPart{Type: "text", Text: text}
+ if !differentModel {
+ ui.ProviderMetadata = part.Metadata.Raw()
+ }
+ assistantMessage.Parts = append(assistantMessage.Parts, ui)
+
+ case StepStartPart:
+ assistantMessage.Parts = append(assistantMessage.Parts, UIPart{Type: "step-start"})
+
+ case ToolPart:
+ if !seenTool[part.Tool] {
+ seenTool[part.Tool] = true
+ toolNames = append(toolNames, part.Tool)
+ }
+ providerExecuted := part.ProviderExecuted()
+ callMeta := providerMeta(part.Metadata)
+
+ switch state := part.State.(type) {
+ case ToolStateCompleted:
+ // `time.compacted` is read for TRUTHINESS, so
+ // a stored 0 behaves as "not compacted".
+ compacted := state.Time.Compacted != nil && *state.Time.Compacted != 0
+ outputText := "[Old tool result content cleared]"
+ if !compacted {
+ outputText = TruncateToolOutput(state.Output, options.toolOutputMaxChars())
+ }
+ var attachments []FilePart
+ if !compacted && !options.stripMedia() && state.Attachments != nil {
+ attachments = *state.Attachments
+ }
+
+ var finalAttachments []FilePart
+ for _, a := range attachments {
+ if IsMedia(a.Mime) && !supportsMediaInToolResult(model, a.Mime) {
+ media = append(media, mediaAttachment{Mime: a.Mime, URL: a.URL, Filename: a.Filename})
+ }
+ if !IsMedia(a.Mime) || supportsMediaInToolResult(model, a.Mime) {
+ finalAttachments = append(finalAttachments, a)
+ }
+ }
+
+ output := jsonString(outputText)
+ if len(finalAttachments) > 0 {
+ output = encodeToolOutputObject(outputText, finalAttachments)
+ }
+
+ ui := UIPart{
+ Type: "tool-" + part.Tool,
+ State: UIToolOutputAvailable,
+ ToolCallID: part.CallID,
+ Input: state.Input.Value(),
+ Output: output,
+ }
+ if providerExecuted {
+ ui.ProviderExecuted = jsonTrue
+ }
+ if !differentModel {
+ ui.CallProviderMetadata = callMeta
+ }
+ assistantMessage.Parts = append(assistantMessage.Parts, ui)
+
+ case ToolStateError:
+ // Only an `interrupted === true` metadata bag can carry a
+ // replayable output; the cleanup drain never writes
+ // `metadata.output`, so in practice this lands on the
+ // output-error branch.
+ var replay string
+ replayable := false
+ if state.Metadata.StrictTrue("interrupted") {
+ if s, ok := state.Metadata.StringField("output"); ok {
+ replay, replayable = s, true
+ }
+ }
+ ui := UIPart{
+ Type: "tool-" + part.Tool,
+ ToolCallID: part.CallID,
+ Input: state.Input.Value(),
+ }
+ if replayable {
+ ui.State = UIToolOutputAvailable
+ ui.Output = jsonString(replay)
+ } else {
+ ui.State = UIToolOutputError
+ ui.ErrorText = state.Error
+ }
+ if providerExecuted {
+ ui.ProviderExecuted = jsonTrue
+ }
+ if !differentModel {
+ ui.CallProviderMetadata = callMeta
+ }
+ assistantMessage.Parts = append(assistantMessage.Parts, ui)
+
+ case ToolStatePending, ToolStateRunning:
+ // Pending/running replay as an error so no
+ // tool_use block is left dangling.
+ ui := UIPart{
+ Type: "tool-" + part.Tool,
+ State: UIToolOutputError,
+ ToolCallID: part.CallID,
+ Input: part.State.ToolInput().Value(),
+ ErrorText: "[Tool execution was interrupted]",
+ }
+ if providerExecuted {
+ ui.ProviderExecuted = jsonTrue
+ }
+ if !differentModel {
+ ui.CallProviderMetadata = callMeta
+ }
+ assistantMessage.Parts = append(assistantMessage.Parts, ui)
+ _ = state
+ }
+
+ case ReasoningPart:
+ if differentModel {
+ // Downgrade to text, or DROP the part entirely
+ // when it trims to nothing.
+ if strings.TrimSpace(part.Text) != "" {
+ assistantMessage.Parts = append(assistantMessage.Parts, UIPart{Type: "text", Text: part.Text})
+ }
+ continue
+ }
+ assistantMessage.Parts = append(assistantMessage.Parts, UIPart{
+ Type: "reasoning",
+ Text: part.Text,
+ // part.metadata passes straight through, NOT via
+ // providerMeta(), unlike every tool branch above.
+ ProviderMetadata: part.Metadata.Raw(),
+ })
+ }
+ }
+
+ if len(assistantMessage.Parts) > 0 {
+ result = append(result, assistantMessage)
+ if len(media) > 0 {
+ // The synthetic user message carrying the extracted media.
+ parts := []UIPart{{Type: "text", Text: SyntheticAttachmentPrompt}}
+ for _, a := range media {
+ parts = append(parts, UIPart{
+ Type: "file",
+ URL: a.URL,
+ MediaType: a.Mime,
+ Filename: optionalStringValue(a.Filename),
+ })
+ }
+ result = append(result, UIMessage{ID: messageIDAscending(), Role: "user", Parts: parts})
+ }
+ }
+ }
+
+ tools := make(map[string]ToolModelOutputFn, len(toolNames))
+ for _, name := range toolNames {
+ tools[name] = toModelOutput
+ }
+
+ // Drop any UIMessage whose parts are ALL step-start.
+ filtered := make([]UIMessage, 0, len(result))
+ for _, msg := range result {
+ keep := false
+ for _, part := range msg.Parts {
+ if part.Type != "step-start" {
+ keep = true
+ break
+ }
+ }
+ if keep {
+ filtered = append(filtered, msg)
+ }
+ }
+
+ return ConvertToModelMessages(filtered, &ConvertOptions{Tools: tools})
+}
+
+// ── small helpers ────────────────────────────────────────────────────────
+
+type mediaAttachment struct {
+ Mime string
+ URL string
+ Filename *string
+}
+
+var jsonTrue = RawValue("true")
+
+func boolValue(b *bool) bool { return b != nil && *b }
+
+func optionalStringValue(s *string) RawValue {
+ if s == nil {
+ return nil
+ }
+ return jsonString(*s)
+}
+
+func isJSONNull(raw []byte) bool {
+ return len(raw) == 0 || string(trimSpace(raw)) == "null"
+}
+
+// isJSONObjectish reports an object, an array or null. An array falls through
+// harmlessly (no text, no attachments) and null yields
+// `{type:"content", value:[]}`; the only callers set `output` to a string or
+// to `{text, attachments}`, so neither branch is reached.
+func isJSONObjectish(raw []byte) bool {
+ t := trimSpace(raw)
+ if len(t) == 0 {
+ return false
+ }
+ return t[0] == '{' || t[0] == '[' || string(t) == "null"
+}
+
+func attachmentList(obj RawObject) []RawObject {
+ raw, ok := obj.Field("attachments")
+ if !ok || isJSONNull(raw) {
+ return nil
+ }
+ items, ok := arrayElements(raw)
+ if !ok {
+ return nil
+ }
+ out := make([]RawObject, 0, len(items))
+ for _, item := range items {
+ out = append(out, RawObject(item))
+ }
+ return out
+}
+
+// encodeToolOutputObject builds `{text, attachments}` with the attachment
+// parts kept verbatim, so the FilePart bytes that reach toModelOutput are the
+// stored ones.
+func encodeToolOutputObject(text string, attachments []FilePart) RawValue {
+ payload := struct {
+ Text string `json:"text"`
+ Attachments []FilePart `json:"attachments"`
+ }{Text: text, Attachments: attachments}
+ raw, err := jsonutil.Marshal(payload)
+ if err != nil {
+ return nil
+ }
+ return raw
+}
diff --git a/internal/seniordev/engine/msgmodel/toolstate.go b/internal/seniordev/engine/msgmodel/toolstate.go
new file mode 100644
index 0000000000..bb46495e4a
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/toolstate.go
@@ -0,0 +1,189 @@
+//go:build !windows
+
+package msgmodel
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// ToolState is the four-variant tool state union, discriminated on `status`.
+// The field sets differ: `pending` has no `time` at all, which is why the
+// cleanup drain falls back to `now`.
+type ToolState interface {
+ ToolStatus() string
+ // Input is `Record`, present on every variant.
+ ToolInput() RawObject
+ // ToolMetadata is the variant's `metadata`, or a zero RawObject when the
+ // variant has none (pending).
+ ToolMetadata() RawObject
+ // StartTime is `state.time.start`; ok=false for `pending`.
+ StartTime() (uint64, bool)
+ json.Marshaler
+}
+
+// ── pending ──────────────────────────────────────
+
+type ToolStatePending struct {
+ Status string `json:"status"`
+ Input RawObject `json:"input"`
+ Raw string `json:"raw"`
+}
+
+func (s ToolStatePending) ToolStatus() string { return ToolStatusPending }
+func (s ToolStatePending) ToolInput() RawObject { return s.Input }
+func (s ToolStatePending) ToolMetadata() RawObject { return nil }
+func (s ToolStatePending) StartTime() (uint64, bool) { return 0, false }
+func (s ToolStatePending) MarshalJSON() ([]byte, error) {
+ type alias ToolStatePending
+ s.Status = ToolStatusPending
+ return tagged(alias(s))
+}
+
+// ── running ──────────────────────────────────────
+
+// ToolTimeStart is ToolStateRunning.time.
+type ToolTimeStart struct {
+ Start uint64 `json:"start"`
+}
+
+type ToolStateRunning struct {
+ Status string `json:"status"`
+ Input RawObject `json:"input"`
+ Title *string `json:"title,omitempty"`
+ Metadata RawObject `json:"metadata,omitempty"`
+ Time ToolTimeStart `json:"time"`
+}
+
+func (s ToolStateRunning) ToolStatus() string { return ToolStatusRunning }
+func (s ToolStateRunning) ToolInput() RawObject { return s.Input }
+func (s ToolStateRunning) ToolMetadata() RawObject { return s.Metadata }
+func (s ToolStateRunning) StartTime() (uint64, bool) { return s.Time.Start, true }
+func (s ToolStateRunning) MarshalJSON() ([]byte, error) {
+ type alias ToolStateRunning
+ s.Status = ToolStatusRunning
+ return tagged(alias(s))
+}
+
+// ── completed ────────────────────────────────────
+//
+// `title` and `metadata` are REQUIRED here, unlike every other variant.
+
+// ToolTimeCompleted is ToolStateCompleted.time.
+type ToolTimeCompleted struct {
+ Start uint64 `json:"start"`
+ End uint64 `json:"end"`
+ Compacted *uint64 `json:"compacted,omitempty"`
+}
+
+type ToolStateCompleted struct {
+ Status string `json:"status"`
+ Input RawObject `json:"input"`
+ Output string `json:"output"`
+ Title string `json:"title"`
+ Metadata RawObject `json:"metadata"`
+ Time ToolTimeCompleted `json:"time"`
+ Attachments *[]FilePart `json:"attachments,omitempty"`
+}
+
+func (s ToolStateCompleted) ToolStatus() string { return ToolStatusCompleted }
+func (s ToolStateCompleted) ToolInput() RawObject { return s.Input }
+func (s ToolStateCompleted) ToolMetadata() RawObject { return s.Metadata }
+func (s ToolStateCompleted) StartTime() (uint64, bool) { return s.Time.Start, true }
+func (s ToolStateCompleted) MarshalJSON() ([]byte, error) {
+ type alias ToolStateCompleted
+ s.Status = ToolStatusCompleted
+ return tagged(alias(s))
+}
+
+// ── error ────────────────────────────────────────
+
+// ToolTimeSpan is ToolStateError.time.
+type ToolTimeSpan struct {
+ Start uint64 `json:"start"`
+ End uint64 `json:"end"`
+}
+
+type ToolStateError struct {
+ Status string `json:"status"`
+ Input RawObject `json:"input"`
+ Error string `json:"error"`
+ Metadata RawObject `json:"metadata,omitempty"`
+ Time ToolTimeSpan `json:"time"`
+}
+
+func (s ToolStateError) ToolStatus() string { return ToolStatusError }
+func (s ToolStateError) ToolInput() RawObject { return s.Input }
+func (s ToolStateError) ToolMetadata() RawObject { return s.Metadata }
+func (s ToolStateError) StartTime() (uint64, bool) { return s.Time.Start, true }
+func (s ToolStateError) MarshalJSON() ([]byte, error) {
+ type alias ToolStateError
+ s.Status = ToolStatusError
+ return tagged(alias(s))
+}
+
+// ── union decode ─────────────────────────────────────────────────────────
+
+// UnmarshalToolState dispatches on `status`.
+func UnmarshalToolState(raw []byte) (ToolState, error) {
+ var probe struct {
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return nil, err
+ }
+ switch probe.Status {
+ case ToolStatusPending:
+ var s ToolStatePending
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, err
+ }
+ return s, nil
+ case ToolStatusRunning:
+ var s ToolStateRunning
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, err
+ }
+ return s, nil
+ case ToolStatusCompleted:
+ var s ToolStateCompleted
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, err
+ }
+ return s, nil
+ case ToolStatusError:
+ var s ToolStateError
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, err
+ }
+ return s, nil
+ }
+ return nil, fmt.Errorf("msgmodel: unknown tool state status %q", probe.Status)
+}
+
+// UnmarshalJSON on ToolPart has to route `state` through the union decoder.
+func (p *ToolPart) UnmarshalJSON(b []byte) error {
+ type alias struct {
+ PartBase
+ Type string `json:"type"`
+ CallID string `json:"callID"`
+ Tool string `json:"tool"`
+ State json.RawMessage `json:"state"`
+ Metadata RawObject `json:"metadata"`
+ }
+ var a alias
+ if err := json.Unmarshal(b, &a); err != nil {
+ return err
+ }
+ state, err := UnmarshalToolState(a.State)
+ if err != nil {
+ return err
+ }
+ p.PartBase = a.PartBase
+ p.Type = a.Type
+ p.CallID = a.CallID
+ p.Tool = a.Tool
+ p.State = state
+ p.Metadata = a.Metadata
+ return nil
+}
diff --git a/internal/seniordev/engine/msgmodel/uimessage.go b/internal/seniordev/engine/msgmodel/uimessage.go
new file mode 100644
index 0000000000..fbaded5929
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/uimessage.go
@@ -0,0 +1,173 @@
+//go:build !windows
+
+package msgmodel
+
+import "encoding/json"
+
+// UIMessage / UIPart are the intermediate value ToModelMessages builds before
+// handing it to ConvertToModelMessages.
+//
+// A UIPart is ONE flat struct rather than a Go union: the parts are
+// duck-typed on their `type` string, and the tool discriminant is a DYNAMIC
+// string `"tool-" + toolName`, so an interface would buy nothing. UIMessages
+// are never serialised as an output, so field order here is documentation
+// rather than contract.
+type UIMessage struct {
+ ID string `json:"id"`
+ Role string `json:"role"`
+ Parts []UIPart `json:"parts"`
+}
+
+type UIPart struct {
+ Type string `json:"type"`
+
+ // text / reasoning
+ Text string `json:"text,omitempty"`
+
+ // text / file / reasoning
+ ProviderMetadata RawValue `json:"providerMetadata,omitempty"`
+
+ // file
+ MediaType string `json:"mediaType,omitempty"`
+ Filename RawValue `json:"filename,omitempty"`
+ URL string `json:"url,omitempty"`
+
+ // dynamic-tool
+ ToolName string `json:"toolName,omitempty"`
+
+ // tool-* / dynamic-tool
+ ToolCallID string `json:"toolCallId,omitempty"`
+ State string `json:"state,omitempty"`
+ Input RawValue `json:"input,omitempty"`
+ RawInput RawValue `json:"rawInput,omitempty"`
+ Output RawValue `json:"output,omitempty"`
+ ErrorText string `json:"errorText,omitempty"`
+ ProviderExecuted RawValue `json:"providerExecuted,omitempty"`
+ CallProviderMetadata RawValue `json:"callProviderMetadata,omitempty"`
+ ResultProviderMetadata RawValue `json:"resultProviderMetadata,omitempty"`
+}
+
+// UI part `state` values.
+const (
+ UIToolInputStreaming = "input-streaming"
+ UIToolInputAvailable = "input-available"
+ UIToolOutputAvailable = "output-available"
+ UIToolOutputError = "output-error"
+)
+
+// Part-kind predicates over the `type` string.
+func (p UIPart) isStaticTool() bool { return len(p.Type) >= 5 && p.Type[:5] == "tool-" }
+func (p UIPart) isDynamicTool() bool { return p.Type == "dynamic-tool" }
+func (p UIPart) isTool() bool { return p.isStaticTool() || p.isDynamicTool() }
+func (p UIPart) isData() bool { return len(p.Type) >= 5 && p.Type[:5] == "data-" }
+func (p UIPart) isText() bool { return p.Type == "text" }
+func (p UIPart) isFile() bool { return p.Type == "file" }
+func (p UIPart) isReasoning() bool { return p.Type == "reasoning" }
+
+// ResolveToolName returns the tool a part refers to: a dynamic part carries
+// the name; a static part's name is everything after the first dash of its
+// type, which preserves every internal dash. That is load-bearing because the
+// type is built as `"tool-" + part.tool` and tool names contain dashes.
+func (p UIPart) ResolveToolName() string {
+ if p.isDynamicTool() {
+ return p.ToolName
+ }
+ return staticToolName(p.Type)
+}
+
+func staticToolName(typ string) string {
+ // Everything after the first "-", and "" when there is no "-" at all.
+ for i := 0; i < len(typ); i++ {
+ if typ[i] == '-' {
+ return typ[i+1:]
+ }
+ }
+ return ""
+}
+
+// ── ModelMessage (the convertToModelMessages output) ─────────────────────
+
+// ModelMessage is one entry of the `ModelMessage[]` handed to the provider.
+// `Content` is a string for `role:"system"` and a content-part slice
+// otherwise.
+type ModelMessage struct {
+ Role string `json:"role"`
+ Content any `json:"content"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// The content-part structs below declare fields in the order they reach the
+// wire body.
+
+// TextContent is a text content part.
+type TextContent struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// FileContent is a file content part.
+type FileContent struct {
+ Type string `json:"type"`
+ MediaType string `json:"mediaType"`
+ Filename RawValue `json:"filename,omitempty"`
+ Data string `json:"data"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// ReasoningContent is a reasoning content part. `providerOptions` is copied
+// unconditionally (contrast text/file); an absent value is dropped by
+// `omitempty`.
+type ReasoningContent struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// ToolCallContent is a tool-call content part.
+type ToolCallContent struct {
+ Type string `json:"type"`
+ ToolCallID string `json:"toolCallId"`
+ ToolName string `json:"toolName"`
+ Input RawValue `json:"input,omitempty"`
+ ProviderExecuted RawValue `json:"providerExecuted,omitempty"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// ToolResultContent is a tool-result content part.
+type ToolResultContent struct {
+ Type string `json:"type"`
+ ToolCallID string `json:"toolCallId"`
+ ToolName string `json:"toolName"`
+ Output ToolOutput `json:"output"`
+ ProviderOptions RawValue `json:"providerOptions,omitempty"`
+}
+
+// ToolOutput is a tool result as the model sees it: one of
+// text / json / error-text / error-json / content.
+type ToolOutput struct {
+ Type string `json:"type"`
+ Value any `json:"value"`
+}
+
+// ToolOutputContentText / ToolOutputContentMedia are the two element shapes
+// toModelOutput emits inside `{type:"content"}`.
+type ToolOutputContentText struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+type ToolOutputContentMedia struct {
+ Type string `json:"type"`
+ MediaType string `json:"mediaType"`
+ Data string `json:"data"`
+}
+
+// MessageConversionError is the only error ConvertToModelMessages raises.
+type MessageConversionError struct {
+ Message string
+}
+
+func (e *MessageConversionError) Error() string { return e.Message }
+
+var _ json.Marshaler = Parts(nil)
diff --git a/internal/seniordev/engine/msgmodel/usertext.go b/internal/seniordev/engine/msgmodel/usertext.go
new file mode 100644
index 0000000000..bed401327a
--- /dev/null
+++ b/internal/seniordev/engine/msgmodel/usertext.go
@@ -0,0 +1,18 @@
+//go:build !windows
+
+package msgmodel
+
+// UserText builds a plain-text user message in the one content shape the wire
+// converter accepts: a []any list holding a single TextContent, which is what
+// ConvertToModelMessages emits for the coder's own prompt.
+//
+// The shape is easy to get wrong: a typed []TextContent slice fails the
+// converter's `msg.Content.([]any)` assertion. Every hand-built user message
+// must come from here, and the converter rejects any other shape instead of
+// sending an empty turn.
+func UserText(text string) ModelMessage {
+ return ModelMessage{
+ Role: "user",
+ Content: []any{TextContent{Type: "text", Text: text}},
+ }
+}
diff --git a/internal/seniordev/engine/orclient/body.go b/internal/seniordev/engine/orclient/body.go
new file mode 100644
index 0000000000..5472350bdf
--- /dev/null
+++ b/internal/seniordev/engine/orclient/body.go
@@ -0,0 +1,332 @@
+//go:build !windows
+
+package orclient
+
+// Request assembly: the chat-completions body and the header set.
+//
+// The body is the client's own fields (model, sampling, messages, tools),
+// then the merged provider option bag from config applied on top as a
+// shallow spread, then the streaming flags. The option bag is an unvalidated
+// whole-body override: anything config puts there replaces the matching
+// top-level field. stream_options is emitted only in strict compatibility
+// mode; senior-dev runs in compatible mode.
+
+import (
+ "bytes"
+ "encoding/json"
+ "sort"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+)
+
+// Compatibility modes.
+const (
+ CompatibilityCompatible = "compatible"
+ CompatibilityStrict = "strict"
+)
+
+// Tool is one registered tool as the request sees it. InputSchema is the JSON
+// Schema the provider receives.
+type Tool struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema json.RawMessage `json:"inputSchema"`
+ ProviderOptions json.RawMessage `json:"providerOptions,omitempty"`
+}
+
+// ToolChoice constrains which tool the model may call. senior-dev sends
+// `{"type":"required"}` only for a json_schema output format; otherwise nil.
+type ToolChoice struct {
+ Type string `json:"type"`
+ ToolName string `json:"toolName,omitempty"`
+}
+
+// RequestParams are the inputs to one request.
+type RequestParams struct {
+ // ModelID is the full `